From b0f4fec3b9923afe73b8d773cc7d463e887e23bf Mon Sep 17 00:00:00 2001 From: fakeminjun7321 Date: Sun, 2 Aug 2026 00:17:10 +0900 Subject: [PATCH] feat: expand schedule alerts and add virtual market --- apps/classbot/README.md | 22 +- ...07_assessment_reminders_virtual_market.sql | 291 ++++++++++++++++++ apps/classbot/db/schema.sql | 223 +++++++++++++- apps/classbot/docs/deployment.md | 10 +- apps/classbot/scripts/preflight.js | 3 + apps/classbot/server/README.md | 4 + apps/classbot/server/app.js | 29 +- apps/classbot/server/app.test.js | 92 +++++- .../server/services/chatbot-read-features.js | 30 +- .../services/chatbot-read-features.test.js | 4 +- apps/classbot/server/services/commands.js | 57 +++- .../classbot/server/services/commands.test.js | 26 ++ apps/classbot/server/services/event-policy.js | 6 + .../server/services/notifications.test.js | 36 +++ apps/classbot/server/services/schedule.js | 72 +++-- .../classbot/server/services/schedule.test.js | 97 +++++- .../server/services/timetable-policy.js | 140 +++++++++ .../server/services/virtual-market.js | 139 +++++++++ .../server/services/virtual-market.test.js | 52 ++++ apps/classbot/server/store/memory-store.js | 131 +++++++- .../server/store/schema-security.test.js | 24 +- apps/classbot/server/store/supabase-store.js | 61 +++- .../server/store/supabase-store.test.js | 31 ++ apps/classbot/src/api/client.js | 9 + apps/classbot/src/components/AppShell.jsx | 23 +- apps/classbot/src/components/Editors.jsx | 14 +- apps/classbot/src/data/seed.js | 4 +- apps/classbot/src/portal/PortalMarket.jsx | 130 ++++++++ apps/classbot/src/portal/StudentPortal.jsx | 15 +- apps/classbot/src/styles.css | 123 ++++++++ 30 files changed, 1813 insertions(+), 85 deletions(-) create mode 100644 apps/classbot/db/migrations/007_assessment_reminders_virtual_market.sql create mode 100644 apps/classbot/server/services/event-policy.js create mode 100644 apps/classbot/server/services/timetable-policy.js create mode 100644 apps/classbot/server/services/virtual-market.js create mode 100644 apps/classbot/server/services/virtual-market.test.js create mode 100644 apps/classbot/src/portal/PortalMarket.jsx diff --git a/apps/classbot/README.md b/apps/classbot/README.md index c87b927..e78176b 100644 --- a/apps/classbot/README.md +++ b/apps/classbot/README.md @@ -4,19 +4,31 @@ ## 현재 구현 범위 -- 관리자·학생 공통 메뉴: 오늘, 일정, 공지, 자료실, 구성원, 설정 +- 관리자 메뉴: 오늘, 일정, 공지, 자료실, 구성원, 설정 +- 학생 메뉴: 오늘, 일정, 공지, 자료실, 별도 `가상 주식` 탭, 구성원, 설정 - 학생용 월·주·일 캘린더, 개인별 시간표, 일정 추가, PDF·이미지 드라이브 - 최대 16명 구성원과 기존 Quilo 로그인 세션 기반 접근 제어 -- 카카오 챗봇 명령: `도움말`, `이름 등록 구민준`, `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `파일 리스트`, `김종수T 학습지` +- 카카오 챗봇 명령: `도움말`, `이름 등록 구민준`, `오늘 일정`, `다음 일정`, `이번 달 일정`, `이번 주 남은 일정`, `수행평가 과제 통합 요약`, `시간표 전체`, `9월 23일 시간표`, `파일 리스트`, `김종수T 학습지` - 파일명·별칭이 정확히 일치하면 바로 보내고, 비슷하면 최대 3개 후보를 물은 뒤 `맞아` 또는 번호 응답으로 보낸다. - 반 전체 일정과 구성원별 개인 일정, 개인 일정 대상자에게만 보내는 알림 - 학생은 자기 개인 일정만 생성·수정·삭제하고, 관리자 역할만 반 전체 일정을 생성·수정·삭제한다. - 반 전체 자료는 Google Drive, 개인 자료는 비공개 Supabase 저장소에 보관하고 15분 프록시 링크로만 연다. - Google Drive 운영 계정은 기존 Quilo의 `구민준` 계정을 정확한 이름으로 찾아 연결한다. -- 일정 마감·변경, 평일 아침 시간표, 반 공지 Event API 알림 +- 수행평가 D-7·D-2·D-1·당일, 일정 변경, 평일 아침 시간표, 반 공지 Event API 알림 - Event API 작업 결과 조회와 사용자별 성공·실패 기록 - 일정·공지 생성 멱등성, 명시적 실패 재시도, 감사 로그 - 로컬 메모리 저장소와 운영 Supabase 저장소 +- 실제 종목·시세·현금과 분리된 5개 허구 종목, 1,000 TKN 시작 잔액, 하루 1회 100 TKN 접속 보상, 구성원별 보유·거래 내역 + +## 2026학년도 2학기 시간표 응답 규칙 + +- 과목명은 카카오 응답에서 `수Ⅰ·수Ⅱ·수Ⅲ → 수학`, `독서 → 국어`, `영Ⅰ·영Ⅱ → 영어`, `체2 → 체육`, `확통1 → 확통`으로 통일한다. +- `공1·공2·공3·공강`은 선생님·강의실 값을 노출하지 않고 모두 `공강`으로 표시한다. +- 담당 선생님이 있으면 `일물(박홍T)` 형식으로 표시한다. +- 수요일 5~7교시는 `자율연구`로 표시한다. 외출 일정이 제공되기 전에는 `O/X`를 추정하지 않으며, 추후 제공 데이터로 `자율연구(O)` 또는 `자율연구(X)`를 결정한다. +- 화요일 7~9교시는 기본 `창체`다. 2026년 9월 1·8·15·22일, 10월 20·27일, 11월 17·24일에는 8~9교시를 `동아리`로 표시한다. +- 첨부된 `2026학년도 2학기 학사일정(안)`을 기준으로 공휴일·시험·방학식에는 정규 시간표를 표시하지 않고, 월·목·금요일 대체수업일에는 해당 요일의 개인 시간표를 사용한다. +- 학사일정표가 `(안)`이므로 일정 변경 시 `server/services/timetable-policy.js`의 날짜 정책과 테스트를 함께 갱신한다. ## 로컬 실행 @@ -45,7 +57,7 @@ npm run release:check ## 운영 배포 -1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v5 운영 DB는 [`db/migrations/006_kakao_personal_event_actions.sql`](./db/migrations/006_kakao_personal_event_actions.sql)을 적용한다. +1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v6 운영 DB는 [`db/migrations/007_assessment_reminders_virtual_market.sql`](./db/migrations/007_assessment_reminders_virtual_market.sql)을 적용한다. 2. 기존 Quilo Render 서비스에 Cron·카카오 스킬 비밀값을 설정한다. Supabase 연결과 관리자 로그인은 기존 Quilo 설정을 그대로 쓴다. 3. 기존 Quilo 서비스를 재배포한다. 루트 `postinstall`이 일정 관리 화면을 함께 빌드한다. 4. `GET /schedule/api/health`가 `ok: true`와 `storage: supabase`를 반환하는지 확인한다. @@ -61,7 +73,7 @@ npm run release:check - Event 이름: 관리자센터 값과 `KAKAO_EVENT_NAME`을 정확히 동일하게 설정 - Event API 활성화: 준비가 끝난 뒤 `KAKAO_EVENT_ENABLED=true` - 최초 이름 등록: 챗봇에 명단 이름 그대로 `이름 등록 구민준` 입력. 초대 코드는 필요 없다. -- 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `파일 리스트`처럼 이름 없이 사용 +- 등록 후 조회: `오늘 일정`, `내일 시간표`, `9월 23일 시간표`, `시간표 전체`, `파일 리스트`처럼 이름 없이 사용 - 개인 일정 변경: `내일 영어 과제 추가`, `수학 수행평가 22일로 변경`, `방금 일정 완료`처럼 입력하고 10분 안에 확인. 반 전체 일정은 카카오에서 변경할 수 없음 - 명시적 대상 조회: 미등록 상태이거나 다른 구성원의 공개 일정을 볼 때만 `오늘 일정 등록이름`처럼 이름을 맨 뒤에 입력 diff --git a/apps/classbot/db/migrations/007_assessment_reminders_virtual_market.sql b/apps/classbot/db/migrations/007_assessment_reminders_virtual_market.sql new file mode 100644 index 0000000..75f8765 --- /dev/null +++ b/apps/classbot/db/migrations/007_assessment_reminders_virtual_market.sql @@ -0,0 +1,291 @@ +-- Classbot schema v7: 수행평가 고정 알림 정책과 TKN 전용 허구 가상 주식. + +update public.classbot_events + set reminder_offsets = array[10080, 2880, 1440, 0], updated_at = now() + where category = 'assessment' + and reminder_offsets is distinct from array[10080, 2880, 1440, 0]; + +create or replace function public.classbot_enforce_assessment_reminders() +returns trigger +language plpgsql +set search_path = public +as $$ +begin + if new.category = 'assessment' then + new.reminder_offsets := array[10080, 2880, 1440, 0]; + end if; + return new; +end; +$$; + +drop trigger if exists enforce_assessment_reminders_insert on public.classbot_events; +create trigger enforce_assessment_reminders_insert +before insert on public.classbot_events +for each row execute function public.classbot_enforce_assessment_reminders(); +drop trigger if exists enforce_assessment_reminders_update on public.classbot_events; +create trigger enforce_assessment_reminders_update +before update of category, reminder_offsets on public.classbot_events +for each row execute function public.classbot_enforce_assessment_reminders(); + +create table if not exists public.classbot_token_accounts ( + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + balance bigint not null default 1000 check (balance >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (class_id, member_id), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade +); + +create table if not exists public.classbot_token_ledger ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + kind text not null check (kind in ('daily_reward', 'buy', 'sell')), + amount bigint not null check (amount <> 0), + balance_after bigint not null check (balance_after >= 0), + reference_key text not null check (char_length(reference_key) between 1 and 180), + metadata jsonb not null default '{}'::jsonb check (jsonb_typeof(metadata) = 'object'), + created_at timestamptz not null default now(), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade, + unique (class_id, member_id, reference_key) +); + +create table if not exists public.classbot_market_positions ( + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + symbol text not null check (symbol in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW')), + quantity integer not null default 0 check (quantity >= 0), + average_cost bigint not null default 0 check (average_cost >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (class_id, member_id, symbol), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade +); + +create table if not exists public.classbot_market_trades ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + symbol text not null check (symbol in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW')), + side text not null check (side in ('buy', 'sell')), + quantity integer not null check (quantity between 1 and 1000), + price bigint not null check (price > 0), + total bigint not null check (total = quantity::bigint * price), + request_key text not null check (char_length(request_key) between 1 and 160), + created_at timestamptz not null default now(), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade, + unique (class_id, member_id, request_key) +); + +create index if not exists classbot_token_ledger_member_created_idx + on public.classbot_token_ledger(class_id, member_id, created_at desc); +create index if not exists classbot_market_trades_member_created_idx + on public.classbot_market_trades(class_id, member_id, created_at desc); + +create or replace function public.classbot_initialize_token_account() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.classbot_token_accounts(class_id, member_id, balance) + values (new.class_id, new.id, 1000) + on conflict (class_id, member_id) do nothing; + return new; +end; +$$; + +drop trigger if exists initialize_token_account on public.classbot_members; +create trigger initialize_token_account +after insert on public.classbot_members +for each row execute function public.classbot_initialize_token_account(); +revoke all on function public.classbot_initialize_token_account() from public; + +insert into public.classbot_token_accounts(class_id, member_id, balance) +select class_id, id, 1000 from public.classbot_members +on conflict (class_id, member_id) do nothing; + +create or replace function public.classbot_claim_daily_market_reward( + p_class_id uuid, + p_member_id uuid, + p_reward_date date, + p_amount integer +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + selected_timezone text; + selected_account public.classbot_token_accounts%rowtype; + created_ledger public.classbot_token_ledger%rowtype; + reward_key text := 'daily:' || p_reward_date::text; +begin + if p_amount < 1 or p_amount > 1000 then + raise exception '접속 보상 금액이 올바르지 않습니다.'; + end if; + select c.timezone into selected_timezone + from public.classbot_classes c + join public.classbot_members m on m.class_id = c.id + where c.id = p_class_id and m.id = p_member_id and m.status <> 'left'; + if selected_timezone is null then + raise exception '가상 주식 계정 구성원을 찾을 수 없습니다.'; + end if; + if p_reward_date <> (clock_timestamp() at time zone selected_timezone)::date then + raise exception '오늘 접속 보상만 받을 수 있습니다.'; + end if; + + insert into public.classbot_token_accounts(class_id, member_id, balance) + values (p_class_id, p_member_id, 1000) + on conflict (class_id, member_id) do nothing; + select * into selected_account + from public.classbot_token_accounts + where class_id = p_class_id and member_id = p_member_id + for update; + + if exists ( + select 1 from public.classbot_token_ledger + where class_id = p_class_id and member_id = p_member_id and reference_key = reward_key + ) then + return jsonb_build_object('claimed', false, 'balance', selected_account.balance); + end if; + + update public.classbot_token_accounts + set balance = balance + p_amount + where class_id = p_class_id and member_id = p_member_id + returning * into selected_account; + insert into public.classbot_token_ledger(class_id, member_id, kind, amount, balance_after, reference_key, metadata) + values (p_class_id, p_member_id, 'daily_reward', p_amount, selected_account.balance, reward_key, jsonb_build_object('reward_date', p_reward_date)) + returning * into created_ledger; + return jsonb_build_object('claimed', true, 'ledger', to_jsonb(created_ledger)); +end; +$$; + +create or replace function public.classbot_execute_market_trade( + p_class_id uuid, + p_member_id uuid, + p_symbol text, + p_side text, + p_quantity integer, + p_price bigint, + p_request_key text +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + selected_account public.classbot_token_accounts%rowtype; + selected_position public.classbot_market_positions%rowtype; + existing_trade public.classbot_market_trades%rowtype; + created_trade public.classbot_market_trades%rowtype; + order_total bigint; + next_quantity integer; +begin + p_symbol := upper(trim(p_symbol)); + p_side := lower(trim(p_side)); + p_request_key := trim(p_request_key); + if p_symbol not in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW') then raise exception '존재하지 않는 가상 종목입니다.'; end if; + if p_side not in ('buy', 'sell') then raise exception '매수 또는 매도를 선택해 주세요.'; end if; + if p_quantity < 1 or p_quantity > 1000 then raise exception '주문 수량은 1~1000주 사이여야 합니다.'; end if; + if p_price < 1 or p_price > 1000000 then raise exception '가상 종목 가격이 올바르지 않습니다.'; end if; + if p_request_key = '' or char_length(p_request_key) > 160 then raise exception '주문 식별값이 올바르지 않습니다.'; end if; + if not exists ( + select 1 from public.classbot_members + where class_id = p_class_id and id = p_member_id and status <> 'left' + ) then raise exception '가상 주식 계정 구성원을 찾을 수 없습니다.'; end if; + + insert into public.classbot_token_accounts(class_id, member_id, balance) + values (p_class_id, p_member_id, 1000) + on conflict (class_id, member_id) do nothing; + select * into selected_account + from public.classbot_token_accounts + where class_id = p_class_id and member_id = p_member_id + for update; + + select * into existing_trade + from public.classbot_market_trades + where class_id = p_class_id and member_id = p_member_id and request_key = p_request_key; + if found then return to_jsonb(existing_trade); end if; + + insert into public.classbot_market_positions(class_id, member_id, symbol, quantity, average_cost) + values (p_class_id, p_member_id, p_symbol, 0, 0) + on conflict (class_id, member_id, symbol) do nothing; + select * into selected_position + from public.classbot_market_positions + where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol + for update; + + order_total := p_quantity::bigint * p_price; + if p_side = 'buy' then + if selected_account.balance < order_total then raise exception '보유 토큰이 부족해 매수할 수 없습니다.'; end if; + next_quantity := selected_position.quantity + p_quantity; + update public.classbot_market_positions + set quantity = next_quantity, + average_cost = round((selected_position.average_cost * selected_position.quantity + order_total)::numeric / next_quantity)::bigint + where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol; + update public.classbot_token_accounts + set balance = balance - order_total + where class_id = p_class_id and member_id = p_member_id + returning * into selected_account; + else + if selected_position.quantity < p_quantity then raise exception '보유 수량이 부족해 매도할 수 없습니다.'; end if; + next_quantity := selected_position.quantity - p_quantity; + update public.classbot_market_positions + set quantity = next_quantity, + average_cost = case when next_quantity = 0 then 0 else average_cost end + where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol; + update public.classbot_token_accounts + set balance = balance + order_total + where class_id = p_class_id and member_id = p_member_id + returning * into selected_account; + end if; + + insert into public.classbot_market_trades(class_id, member_id, symbol, side, quantity, price, total, request_key) + values (p_class_id, p_member_id, p_symbol, p_side, p_quantity, p_price, order_total, p_request_key) + returning * into created_trade; + insert into public.classbot_token_ledger(class_id, member_id, kind, amount, balance_after, reference_key, metadata) + values ( + p_class_id, p_member_id, p_side, + case when p_side = 'buy' then -order_total else order_total end, + selected_account.balance, 'trade:' || p_request_key, + jsonb_build_object('trade_id', created_trade.id, 'symbol', p_symbol, 'quantity', p_quantity, 'price', p_price) + ); + return to_jsonb(created_trade); +end; +$$; + +revoke all on function public.classbot_claim_daily_market_reward(uuid, uuid, date, integer) from public; +revoke all on function public.classbot_execute_market_trade(uuid, uuid, text, text, integer, bigint, text) from public; +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'grant execute on function public.classbot_claim_daily_market_reward(uuid, uuid, date, integer) to service_role'; + execute 'grant execute on function public.classbot_execute_market_trade(uuid, uuid, text, text, integer, bigint, text) to service_role'; + end if; +end; +$$; + +drop trigger if exists set_updated_at on public.classbot_token_accounts; +create trigger set_updated_at before update on public.classbot_token_accounts +for each row execute function public.classbot_set_updated_at(); +drop trigger if exists set_updated_at on public.classbot_market_positions; +create trigger set_updated_at before update on public.classbot_market_positions +for each row execute function public.classbot_set_updated_at(); + +alter table public.classbot_token_accounts enable row level security; +alter table public.classbot_token_ledger enable row level security; +alter table public.classbot_market_positions enable row level security; +alter table public.classbot_market_trades enable row level security; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 7, now()) +on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; diff --git a/apps/classbot/db/schema.sql b/apps/classbot/db/schema.sql index ff9da64..aed4456 100644 --- a/apps/classbot/db/schema.sql +++ b/apps/classbot/db/schema.sql @@ -7,7 +7,7 @@ create table if not exists public.classbot_schema_meta ( ); insert into public.classbot_schema_meta(id, version, applied_at) -values (1, 6, now()) +values (1, 7, now()) on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; create or replace function public.classbot_health_check() @@ -785,3 +785,224 @@ alter table public.classbot_audit_logs enable row level security; -- No anon/authenticated policies are created. The server uses the service role key, -- and all browser access goes through the authenticated Classbot admin API. + +-- Schema v7: 수행평가 고정 알림과 TKN 전용 허구 가상 주식. +update public.classbot_events + set reminder_offsets = array[10080, 2880, 1440, 0], updated_at = now() + where category = 'assessment' + and reminder_offsets is distinct from array[10080, 2880, 1440, 0]; + +create or replace function public.classbot_enforce_assessment_reminders() +returns trigger language plpgsql set search_path = public +as $$ +begin + if new.category = 'assessment' then + new.reminder_offsets := array[10080, 2880, 1440, 0]; + end if; + return new; +end; +$$; + +drop trigger if exists enforce_assessment_reminders_insert on public.classbot_events; +create trigger enforce_assessment_reminders_insert before insert on public.classbot_events +for each row execute function public.classbot_enforce_assessment_reminders(); +drop trigger if exists enforce_assessment_reminders_update on public.classbot_events; +create trigger enforce_assessment_reminders_update before update of category, reminder_offsets on public.classbot_events +for each row execute function public.classbot_enforce_assessment_reminders(); + +create table if not exists public.classbot_token_accounts ( + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + balance bigint not null default 1000 check (balance >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (class_id, member_id), + foreign key (class_id, member_id) references public.classbot_members(class_id, id) on delete cascade +); + +create table if not exists public.classbot_token_ledger ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + kind text not null check (kind in ('daily_reward', 'buy', 'sell')), + amount bigint not null check (amount <> 0), + balance_after bigint not null check (balance_after >= 0), + reference_key text not null check (char_length(reference_key) between 1 and 180), + metadata jsonb not null default '{}'::jsonb check (jsonb_typeof(metadata) = 'object'), + created_at timestamptz not null default now(), + foreign key (class_id, member_id) references public.classbot_members(class_id, id) on delete cascade, + unique (class_id, member_id, reference_key) +); + +create table if not exists public.classbot_market_positions ( + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + symbol text not null check (symbol in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW')), + quantity integer not null default 0 check (quantity >= 0), + average_cost bigint not null default 0 check (average_cost >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (class_id, member_id, symbol), + foreign key (class_id, member_id) references public.classbot_members(class_id, id) on delete cascade +); + +create table if not exists public.classbot_market_trades ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classbot_classes(id) on delete cascade, + member_id uuid not null references public.classbot_members(id) on delete cascade, + symbol text not null check (symbol in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW')), + side text not null check (side in ('buy', 'sell')), + quantity integer not null check (quantity between 1 and 1000), + price bigint not null check (price > 0), + total bigint not null check (total = quantity::bigint * price), + request_key text not null check (char_length(request_key) between 1 and 160), + created_at timestamptz not null default now(), + foreign key (class_id, member_id) references public.classbot_members(class_id, id) on delete cascade, + unique (class_id, member_id, request_key) +); + +create index if not exists classbot_token_ledger_member_created_idx + on public.classbot_token_ledger(class_id, member_id, created_at desc); +create index if not exists classbot_market_trades_member_created_idx + on public.classbot_market_trades(class_id, member_id, created_at desc); + +create or replace function public.classbot_initialize_token_account() +returns trigger language plpgsql security definer set search_path = public +as $$ +begin + insert into public.classbot_token_accounts(class_id, member_id, balance) + values (new.class_id, new.id, 1000) + on conflict (class_id, member_id) do nothing; + return new; +end; +$$; + +drop trigger if exists initialize_token_account on public.classbot_members; +create trigger initialize_token_account after insert on public.classbot_members +for each row execute function public.classbot_initialize_token_account(); +revoke all on function public.classbot_initialize_token_account() from public; + +insert into public.classbot_token_accounts(class_id, member_id, balance) +select class_id, id, 1000 from public.classbot_members +on conflict (class_id, member_id) do nothing; + +create or replace function public.classbot_claim_daily_market_reward( + p_class_id uuid, p_member_id uuid, p_reward_date date, p_amount integer +) +returns jsonb language plpgsql security definer set search_path = public +as $$ +declare + selected_timezone text; + selected_account public.classbot_token_accounts%rowtype; + created_ledger public.classbot_token_ledger%rowtype; + reward_key text := 'daily:' || p_reward_date::text; +begin + if p_amount < 1 or p_amount > 1000 then raise exception '접속 보상 금액이 올바르지 않습니다.'; end if; + select c.timezone into selected_timezone + from public.classbot_classes c join public.classbot_members m on m.class_id = c.id + where c.id = p_class_id and m.id = p_member_id and m.status <> 'left'; + if selected_timezone is null then raise exception '가상 주식 계정 구성원을 찾을 수 없습니다.'; end if; + if p_reward_date <> (clock_timestamp() at time zone selected_timezone)::date then + raise exception '오늘 접속 보상만 받을 수 있습니다.'; + end if; + insert into public.classbot_token_accounts(class_id, member_id, balance) + values (p_class_id, p_member_id, 1000) on conflict (class_id, member_id) do nothing; + select * into selected_account from public.classbot_token_accounts + where class_id = p_class_id and member_id = p_member_id for update; + if exists (select 1 from public.classbot_token_ledger where class_id = p_class_id and member_id = p_member_id and reference_key = reward_key) then + return jsonb_build_object('claimed', false, 'balance', selected_account.balance); + end if; + update public.classbot_token_accounts set balance = balance + p_amount + where class_id = p_class_id and member_id = p_member_id returning * into selected_account; + insert into public.classbot_token_ledger(class_id, member_id, kind, amount, balance_after, reference_key, metadata) + values (p_class_id, p_member_id, 'daily_reward', p_amount, selected_account.balance, reward_key, jsonb_build_object('reward_date', p_reward_date)) + returning * into created_ledger; + return jsonb_build_object('claimed', true, 'ledger', to_jsonb(created_ledger)); +end; +$$; + +create or replace function public.classbot_execute_market_trade( + p_class_id uuid, p_member_id uuid, p_symbol text, p_side text, + p_quantity integer, p_price bigint, p_request_key text +) +returns jsonb language plpgsql security definer set search_path = public +as $$ +declare + selected_account public.classbot_token_accounts%rowtype; + selected_position public.classbot_market_positions%rowtype; + existing_trade public.classbot_market_trades%rowtype; + created_trade public.classbot_market_trades%rowtype; + order_total bigint; + next_quantity integer; +begin + p_symbol := upper(trim(p_symbol)); p_side := lower(trim(p_side)); p_request_key := trim(p_request_key); + if p_symbol not in ('QLR', 'BLW', 'NXT', 'GCR', 'SPW') then raise exception '존재하지 않는 가상 종목입니다.'; end if; + if p_side not in ('buy', 'sell') then raise exception '매수 또는 매도를 선택해 주세요.'; end if; + if p_quantity < 1 or p_quantity > 1000 then raise exception '주문 수량은 1~1000주 사이여야 합니다.'; end if; + if p_price < 1 or p_price > 1000000 then raise exception '가상 종목 가격이 올바르지 않습니다.'; end if; + if p_request_key = '' or char_length(p_request_key) > 160 then raise exception '주문 식별값이 올바르지 않습니다.'; end if; + if not exists (select 1 from public.classbot_members where class_id = p_class_id and id = p_member_id and status <> 'left') then + raise exception '가상 주식 계정 구성원을 찾을 수 없습니다.'; + end if; + insert into public.classbot_token_accounts(class_id, member_id, balance) + values (p_class_id, p_member_id, 1000) on conflict (class_id, member_id) do nothing; + select * into selected_account from public.classbot_token_accounts + where class_id = p_class_id and member_id = p_member_id for update; + select * into existing_trade from public.classbot_market_trades + where class_id = p_class_id and member_id = p_member_id and request_key = p_request_key; + if found then return to_jsonb(existing_trade); end if; + insert into public.classbot_market_positions(class_id, member_id, symbol, quantity, average_cost) + values (p_class_id, p_member_id, p_symbol, 0, 0) on conflict (class_id, member_id, symbol) do nothing; + select * into selected_position from public.classbot_market_positions + where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol for update; + order_total := p_quantity::bigint * p_price; + if p_side = 'buy' then + if selected_account.balance < order_total then raise exception '보유 토큰이 부족해 매수할 수 없습니다.'; end if; + next_quantity := selected_position.quantity + p_quantity; + update public.classbot_market_positions set quantity = next_quantity, + average_cost = round((selected_position.average_cost * selected_position.quantity + order_total)::numeric / next_quantity)::bigint + where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol; + update public.classbot_token_accounts set balance = balance - order_total + where class_id = p_class_id and member_id = p_member_id returning * into selected_account; + else + if selected_position.quantity < p_quantity then raise exception '보유 수량이 부족해 매도할 수 없습니다.'; end if; + next_quantity := selected_position.quantity - p_quantity; + update public.classbot_market_positions set quantity = next_quantity, + average_cost = case when next_quantity = 0 then 0 else average_cost end + where class_id = p_class_id and member_id = p_member_id and symbol = p_symbol; + update public.classbot_token_accounts set balance = balance + order_total + where class_id = p_class_id and member_id = p_member_id returning * into selected_account; + end if; + insert into public.classbot_market_trades(class_id, member_id, symbol, side, quantity, price, total, request_key) + values (p_class_id, p_member_id, p_symbol, p_side, p_quantity, p_price, order_total, p_request_key) + returning * into created_trade; + insert into public.classbot_token_ledger(class_id, member_id, kind, amount, balance_after, reference_key, metadata) + values (p_class_id, p_member_id, p_side, case when p_side = 'buy' then -order_total else order_total end, + selected_account.balance, 'trade:' || p_request_key, + jsonb_build_object('trade_id', created_trade.id, 'symbol', p_symbol, 'quantity', p_quantity, 'price', p_price)); + return to_jsonb(created_trade); +end; +$$; + +revoke all on function public.classbot_claim_daily_market_reward(uuid, uuid, date, integer) from public; +revoke all on function public.classbot_execute_market_trade(uuid, uuid, text, text, integer, bigint, text) from public; +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'grant execute on function public.classbot_claim_daily_market_reward(uuid, uuid, date, integer) to service_role'; + execute 'grant execute on function public.classbot_execute_market_trade(uuid, uuid, text, text, integer, bigint, text) to service_role'; + end if; +end; +$$; + +drop trigger if exists set_updated_at on public.classbot_token_accounts; +create trigger set_updated_at before update on public.classbot_token_accounts +for each row execute function public.classbot_set_updated_at(); +drop trigger if exists set_updated_at on public.classbot_market_positions; +create trigger set_updated_at before update on public.classbot_market_positions +for each row execute function public.classbot_set_updated_at(); + +alter table public.classbot_token_accounts enable row level security; +alter table public.classbot_token_ledger enable row level security; +alter table public.classbot_market_positions enable row level security; +alter table public.classbot_market_trades enable row level security; diff --git a/apps/classbot/docs/deployment.md b/apps/classbot/docs/deployment.md index 17661b1..d4a55c0 100644 --- a/apps/classbot/docs/deployment.md +++ b/apps/classbot/docs/deployment.md @@ -29,10 +29,12 @@ select to_regprocedure('public.classbot_claim_invite(uuid,text,text,text)') is not null as invite_rpc, to_regprocedure('public.classbot_claim_member_by_name(uuid,text,text,text)') is not null as name_claim_rpc, to_regprocedure('public.classbot_replace_timetable_day(uuid,integer,jsonb)') is not null as timetable_rpc, - to_regprocedure('public.classbot_replace_member_timetable(uuid,uuid,jsonb)') is not null as member_timetable_rpc; + to_regprocedure('public.classbot_replace_member_timetable(uuid,uuid,jsonb)') is not null as member_timetable_rpc, + to_regprocedure('public.classbot_claim_daily_market_reward(uuid,uuid,date,integer)') is not null as market_reward_rpc, + to_regprocedure('public.classbot_execute_market_trade(uuid,uuid,text,text,integer,bigint,text)') is not null as market_trade_rpc; ``` -기대값은 schema version `6`와 모든 RPC의 `true`다. 기존 v5 운영 DB에는 전체 스키마 대신 [`006_kakao_personal_event_actions.sql`](../db/migrations/006_kakao_personal_event_actions.sql)을 적용할 수 있다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. +기대값은 schema version `7`과 모든 RPC의 `true`다. 기존 v6 운영 DB에는 전체 스키마 대신 [`007_assessment_reminders_virtual_market.sql`](../db/migrations/007_assessment_reminders_virtual_market.sql)을 적용할 수 있다. 모든 일정·토큰·가상 주식 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. ## 3. 기존 Render 서비스 설정 @@ -86,10 +88,12 @@ Smoke test는 관리자 로그인을 시도하거나 데이터를 생성하지 2. 챗봇 생성·채널 연결과 봇 배포를 완료한다. 단톡방에서 직접 호출하는 조회 기능만 쓸 때는 사업자 인증이나 월렛이 필요하지 않다. 3. 카카오 스킬에 공개 HTTPS `/schedule/api/kakao/skill` 엔드포인트와 `X-Classbot-Skill-Secret` 헤더를 연결하고 폴백 블록이 스킬 응답을 사용하게 한다. 4. `KAKAO_BOT_ID=6a57ace9fd013545b6416293`을 설정하고, `KAKAO_REST_API_KEY`는 Render에 직접 입력한다. -5. 개발봇 또는 테스트 채널에서 `이름 등록 구민준`처럼 명단의 정확한 이름을 한 번 입력한 뒤 `오늘 일정`, `시간표 전체`, `파일 리스트`를 이름 없이 조회하고 개인 일정·개인 자료 격리를 확인한다. +5. 개발봇 또는 테스트 채널에서 `이름 등록 구민준`처럼 명단의 정확한 이름을 한 번 입력한 뒤 `오늘 일정`, `9월 23일 시간표`, `시간표 전체`, `파일 리스트`를 이름 없이 조회하고 개인 일정·개인 자료 격리를 확인한다. 학생 웹 포털은 `https://quilolab.com/schedule/`에서 기존 Quilo 계정으로 로그인한다. 계정 이름이 16명 명단 중 한 명과 정확히 일치해야 열리며, 학생은 본인 일정만 추가할 수 있고 관리자 역할만 반 전체 공개 범위를 선택할 수 있는지 확인한다. +학생 포털의 `가상 주식` 탭에서는 실제 종목이 아닌 `QLR`, `BLW`, `NXT`, `GCR`, `SPW`만 노출되어야 한다. 같은 날 접속 보상을 두 번 눌러도 100 TKN이 한 번만 지급되는지, 같은 `Idempotency-Key` 주문이 한 번만 체결되는지, 다른 학생의 잔액·보유·거래 내역이 섞이지 않는지 확인한다. TKN은 현금 교환이나 실제 금융 상품과 연결하지 않는다. + 자동 알림을 추가로 켜는 경우에만 비즈니스 채널 인증, 비즈앱, 카카오 로그인, 월렛과 Event 블록을 준비하고 이벤트명을 `quilo_schedule_notification`으로 설정한다. 테스트 구성원 한 명만 활성 수신자로 둔 상태에서 `KAKAO_EVENT_ENABLED=true`로 바꾸고 한 건만 시험 발송한다. POST 접수 후 task 결과가 `sent`로 확정되면 운영 Cron을 활성화하고, 실패하면 즉시 `KAKAO_EVENT_ENABLED=false`로 되돌린다. 실패 알림은 자동 재시도하지 않는다. 원인을 확인한 뒤 관리자 명시적 재시도만 사용한다. diff --git a/apps/classbot/scripts/preflight.js b/apps/classbot/scripts/preflight.js index b5b27a4..655f738 100644 --- a/apps/classbot/scripts/preflight.js +++ b/apps/classbot/scripts/preflight.js @@ -34,6 +34,9 @@ export function runPreflight({ env = process.env } = {}) { "classbot_create_member", "classbot_claim_invite", "classbot_replace_timetable_day", + "classbot_claim_daily_market_reward", + "classbot_execute_market_trade", + "classbot_market_trades", ], "db/schema.sql"); requireIncludes(rootPackage, [ "npm ci --prefix apps/classbot --include=dev", diff --git a/apps/classbot/server/README.md b/apps/classbot/server/README.md index 1e28063..49faa29 100644 --- a/apps/classbot/server/README.md +++ b/apps/classbot/server/README.md @@ -15,10 +15,14 @@ Kakao Event API의 POST `SUCCESS`는 접수 성공일 뿐 실제 발송 성공 일정 알림의 중복 방지 키에는 `event.id`, 현재 `due_at`, reminder offset, member id가 포함된다. 따라서 같은 마감 revision은 한 번만 발송되고, 관리자가 마감을 변경하면 새 일정에 맞춰 다시 예약할 수 있다. +수행평가의 기본 reminder offset은 `10080, 2880, 1440, 0`분(D-7, D-2, D-1, 당일)이다. v7 마이그레이션은 기존 수행평가도 이 네 시점으로 맞춘다. + ## Production requirements 운영 환경은 `CLASSBOT_STORAGE=supabase`만 허용한다. 메모리 저장소는 재시작 시 데이터와 중복 방지 기록을 잃으므로 로컬 개발 전용이다. 세션·Cron·카카오 스킬 secret은 각각 32자 이상, 관리자 비밀번호는 16자 이상이어야 한다. 카카오 스킬 secret은 스킬 URL의 `?secret=` 또는 `X-Classbot-Skill-Secret` 헤더로 전달한다. 구성원 생성, 반 시간표 요일 교체, 개인별 시간표 전체 교체는 Supabase RPC 안에서 대상 행을 잠그고 처리한다. 개인별 시간표 테이블은 RLS를 켜고 anon/authenticated 정책을 만들지 않는다. 관리자 API의 구성원 DTO와 audit JSON에는 `kakao_user_key` 원문을 반환하거나 기록하지 않는다. +가상 주식의 TKN 잔액·보유량 변경도 구성원 계정 행을 잠그는 Supabase RPC에서만 수행한다. 종목은 5개 허구 symbol만 허용하고 가격은 서버가 결정하며, 브라우저가 보낸 가격은 사용하지 않는다. 접속 보상과 주문은 구성원별 unique key로 중복 지급·체결을 막고 모든 관련 테이블에 RLS를 활성화한다. + 학생 포털은 부모 Quilo 서버가 매 요청 새로 검증한 로그인 사용자의 이름을 16명 명단과 정확히 대조한다. 하위 앱에는 Quilo API 토큰을 넘기지 않으며, 학생 요청의 `member_id`는 일치한 구성원으로 서버에서 강제한다. 학생은 다른 구성원 개인 일정·개인 시간표·자료를 조회하거나 수정할 수 없고, 관리자 역할만 반 전체 일정을 관리할 수 있다. 카카오 이름 등록은 `이름 등록 구민준`처럼 명단 이름을 정확히 사용하며, 한 번 다른 카카오 계정에 연결된 이름은 관리자가 초기화하기 전 재등록할 수 없다. diff --git a/apps/classbot/server/app.js b/apps/classbot/server/app.js index fc8bb1e..7e48d93 100644 --- a/apps/classbot/server/app.js +++ b/apps/classbot/server/app.js @@ -24,6 +24,8 @@ import { KakaoEventClient, simpleTextResponse } from "./services/kakao.js"; import { handleKakaoCommand } from "./services/commands.js"; import { NotificationService } from "./services/notifications.js"; import { getUpcomingEvents } from "./services/schedule.js"; +import { defaultReminderOffsets } from "./services/event-policy.js"; +import { claimMarketReward, executeMarketOrder, marketSnapshot } from "./services/virtual-market.js"; import { parseKoreaDateTime } from "./time.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -149,7 +151,9 @@ function eventInput(body, { partial = false } = {}) { } if (body.notify_on_change !== undefined || !partial) input.notify_on_change = booleanValue(body.notify_on_change, true); const offsets = validateReminderOffsets(body.reminder_offsets); - if (offsets !== undefined) input.reminder_offsets = offsets; + if (input.category === "assessment") input.reminder_offsets = defaultReminderOffsets("assessment"); + else if (offsets !== undefined) input.reminder_offsets = offsets; + else if (!partial) input.reminder_offsets = defaultReminderOffsets(input.category); return input; } @@ -553,6 +557,25 @@ export async function createApp(options = {}) { }); })); + app.get("/api/portal/market", requirePortal, asyncRoute(async (req, res) => { + res.json(await marketSnapshot(store, req.portalMember.id, now())); + })); + + app.post("/api/portal/market/reward", requirePortal, asyncRoute(async (req, res) => { + res.json(await claimMarketReward(store, req.portalMember.id, now())); + })); + + app.post("/api/portal/market/orders", requirePortal, asyncRoute(async (req, res) => { + const key = requestKey(req); + if (!key) throw new HttpError(400, "안전한 주문 처리를 위해 Idempotency-Key가 필요합니다."); + res.status(201).json(await executeMarketOrder(store, req.portalMember.id, { + symbol: req.body?.symbol, + side: req.body?.side, + quantity: req.body?.quantity, + requestKey: key, + }, now())); + })); + app.post("/api/portal/events", requirePortal, asyncRoute(async (req, res) => { const requestedScope = String(req.body?.scope || "personal"); const scope = requestedScope === "self" ? "personal" : requestedScope; @@ -905,9 +928,9 @@ export async function createApp(options = {}) { app.use((error, _req, res, _next) => { const inferredStatus = /찾을 수 없습니다/.test(error.message) ? 404 - : /초과|이미|중복|두 번/.test(error.message) + : /초과|이미|중복|두 번|부족/.test(error.message) ? 409 - : /입력|올바른|선택|만료|초대 코드|게시된 공지|재시도|활성 구성원|원본 알림/.test(error.message) + : /입력|올바른|선택|만료|초대 코드|게시된 공지|재시도|활성 구성원|원본 알림|존재하지|주문 수량/.test(error.message) ? 400 : 500; const status = error.status || (error.type === "entity.parse.failed" ? 400 : inferredStatus); diff --git a/apps/classbot/server/app.test.js b/apps/classbot/server/app.test.js index bfaa4b8..51bfcd3 100644 --- a/apps/classbot/server/app.test.js +++ b/apps/classbot/server/app.test.js @@ -267,6 +267,35 @@ test("학생 포털 파일 목록은 반 전체와 본인 자료만 15분 열기 assert.match(downloaded.headers["content-disposition"], /^attachment/); }); +test("가상 주식 API는 포털 본인 계정만 사용하고 보상·주문을 멱등 처리한다", async () => { + const { app, store } = await fixture(); + const member = (await store.listMembers()).find((item) => item.role === "student"); + assert.equal((await request(app).get("/api/portal/market")).status, 401); + + const agent = request.agent(app); + assert.equal((await loginPortal(agent, store, member, "MARK-ET01")).status, 200); + const initial = await agent.get("/api/portal/market"); + assert.equal(initial.status, 200); + assert.equal(initial.body.account.balance, 1000); + assert.equal(initial.body.play_money_only, true); + assert.deepEqual(initial.body.instruments.map((item) => item.symbol), ["QLR", "BLW", "NXT", "GCR", "SPW"]); + + const rewarded = await agent.post("/api/portal/market/reward"); + assert.equal(rewarded.status, 200); + assert.equal(rewarded.body.account.balance, 1100); + assert.equal((await agent.post("/api/portal/market/reward")).body.account.balance, 1100); + + const bought = await agent.post("/api/portal/market/orders").set("Idempotency-Key", "portal-market-buy-1").send({ symbol: "QLR", side: "buy", quantity: 1 }); + assert.equal(bought.status, 201); + assert.equal(bought.body.positions.find((item) => item.symbol === "QLR").owned_quantity, 1); + const duplicate = await agent.post("/api/portal/market/orders").set("Idempotency-Key", "portal-market-buy-1").send({ symbol: "QLR", side: "buy", quantity: 1 }); + assert.equal(duplicate.body.positions.find((item) => item.symbol === "QLR").owned_quantity, 1); + + assert.equal((await agent.post("/api/portal/market/orders").send({ symbol: "QLR", side: "buy", quantity: 1 })).status, 400); + assert.equal((await agent.post("/api/portal/market/orders").set("Idempotency-Key", "real-stock").send({ symbol: "AAPL", side: "buy", quantity: 1 })).status, 400); + assert.equal((await agent.post("/api/portal/market/orders").set("Idempotency-Key", "too-many").send({ symbol: "QLR", side: "sell", quantity: 2 })).status, 409); +}); + test("학생 포털 일정 쓰기는 본인 소유를 강제하고 포털 admin만 반 전체 일정을 관리한다", async () => { const { app, store } = await fixture(); const members = await store.listMembers(); @@ -417,7 +446,20 @@ test("동일 Idempotency-Key 일정 생성은 한 번만 저장한다", async () assert.equal(second.status, 201); assert.equal(first.body.item.id, second.body.item.id); assert.equal((await store.listEvents()).filter((item) => item.title === payload.title).length, 1); - assert.deepEqual(first.body.item.reminder_offsets, [1440, 0]); + assert.deepEqual(first.body.item.reminder_offsets, [10080, 2880, 1440, 0]); +}); + +test("수행평가를 기본값으로 등록하면 1주일·2일·1일·당일 알림을 저장한다", async () => { + const { agent } = await fixture(); + await login(agent); + const response = await agent.post("/api/admin/events").send({ + category: "assessment", + subject: "화학", + title: "화학 수행평가", + due_at: "2026-08-08T12:00:00", + }); + assert.equal(response.status, 201); + assert.deepEqual(response.body.item.reminder_offsets, [10080, 2880, 1440, 0]); }); test("시간표 중복 교시와 잘못된 일정 날짜를 거부한다", async () => { @@ -536,6 +578,54 @@ test("이름이 맨 뒤인 시간표와 오늘·내일·주간 일정 질의는 } }); +test("카카오 스킬 실제 경로는 동아리일·금요일 대체수업·추석 연휴를 날짜별로 반영한다", async () => { + const store = new MemoryStore(config); + const member = store.members[0]; + member.status = "active"; + member.kakao_user_key = "kakao-calendar-user"; + member.kakao_user_key_type = "botUserKey"; + await store.replaceMemberTimetable({ + memberId: member.id, + rows: [ + { weekday: 2, period: 1, subject: "수Ⅰ", teacher: "류상욱", effective_from: "2026-08-01" }, + { weekday: 3, period: 1, subject: "수요일 원본", teacher: "수요일교사", effective_from: "2026-08-01" }, + { weekday: 5, period: 1, subject: "영Ⅱ", teacher: "김선옥", effective_from: "2026-08-01" }, + ], + }); + let current = new Date("2026-09-22T12:00:00+09:00"); + const app = await createApp({ config, store, now: () => current }); + const payload = { userRequest: { utterance: "오늘 시간표", user: { properties: { botUserKey: "kakao-calendar-user" } } } }; + + const club = await request(app).post("/api/kakao/skill").send(payload); + const clubText = club.body.template.outputs[0].simpleText.text; + assert.match(clubText, /1교시 수학\(류상욱T\)/); + assert.match(clubText, /7교시 창체/); + assert.match(clubText, /8교시 동아리/); + assert.match(clubText, /9교시 동아리/); + + current = new Date("2026-09-23T12:00:00+09:00"); + const replacement = await request(app).post("/api/kakao/skill").send(payload); + const replacementText = replacement.body.template.outputs[0].simpleText.text; + assert.match(replacementText, /학사일정: 금요일 수업 · 퇴사/); + assert.match(replacementText, /1교시 영어\(김선옥T\)/); + assert.doesNotMatch(replacementText, /수요일 원본/); + + current = new Date("2026-09-24T12:00:00+09:00"); + const holiday = await request(app).post("/api/kakao/skill").send(payload); + const holidayText = holiday.body.template.outputs[0].simpleText.text; + assert.match(holidayText, /학사일정: 추석 연휴/); + assert.match(holidayText, /정규 수업이 없습니다/); + assert.doesNotMatch(holidayText, /1교시/); + + const explicitDate = await request(app).post("/api/kakao/skill").send({ + userRequest: { utterance: "2026년 9월 23일 시간표", user: payload.userRequest.user }, + }); + const explicitText = explicitDate.body.template.outputs[0].simpleText.text; + assert.match(explicitText, /9월 23일 수요일 시간표/); + assert.match(explicitText, /학사일정: 금요일 수업 · 퇴사/); + assert.match(explicitText, /1교시 영어\(김선옥T\)/); +}); + test("관리자 일정 API는 개인 대상 member_id를 검증하고 반 전체 일정으로 되돌릴 수 있다", async () => { const { agent, store } = await fixture(); await login(agent); diff --git a/apps/classbot/server/services/chatbot-read-features.js b/apps/classbot/server/services/chatbot-read-features.js index 7a189b5..c6251d6 100644 --- a/apps/classbot/server/services/chatbot-read-features.js +++ b/apps/classbot/server/services/chatbot-read-features.js @@ -1,4 +1,6 @@ -import { endOfSeoulDay, getSeoulParts, startOfSeoulDay } from "../time.js"; +import { endOfSeoulDay, startOfSeoulDay } from "../time.js"; +import { getTimetableForDate } from "./schedule.js"; +import { formatTimetableSubject } from "./timetable-policy.js"; const FILE_COMMAND_WORDS = new Set([ "찾기", "찾아줘", "찾아주세요", "검색", "검색해줘", "검색해주세요", @@ -131,12 +133,15 @@ export function rankBrowseFileCandidates(files, spec) { .map(({ file, score }) => ({ file, score })); } -function compactTimetable(rows) { +function compactTimetable(bundle) { + if (bundle.academicDay?.noRegularClasses) return `학사일정: ${bundle.academicDay.label}`; + const rows = bundle.timetable || []; if (!rows.length) return "없음"; - return rows - .slice(0, 8) - .map((row) => `${row.period}교시 ${row.subject}`) + const rowsText = rows + .slice(0, 9) + .map((row) => `${row.period}교시 ${formatTimetableSubject(row)}`) .join(" · "); + return bundle.academicDay ? `학사일정: ${bundle.academicDay.label} · ${rowsText}` : rowsText; } function eventDday(event, now) { @@ -176,17 +181,8 @@ async function optionalList(loader) { } export async function buildTodayBriefing({ store, member, now = new Date() }) { - const parts = getSeoulParts(now); - const [timetable, events, notices, files] = await Promise.all([ - parts.weekday >= 1 && parts.weekday <= 5 - ? (async () => { - if (typeof store.listMemberTimetable === "function") { - const personal = await store.listMemberTimetable(member.id, { weekday: parts.weekday, date: parts.dateKey }); - if (personal.length) return personal; - } - return store.listTimetable({ weekday: parts.weekday }); - })() - : Promise.resolve([]), + const [timetableBundle, events, notices, files] = await Promise.all([ + getTimetableForDate(store, now, { targetMemberId: member.id }), store.listEvents({ from: startOfSeoulDay(now).toISOString(), to: endOfSeoulDay(new Date(startOfSeoulDay(now).getTime() + 2 * 86_400_000)).toISOString(), @@ -212,7 +208,7 @@ export async function buildTodayBriefing({ store, member, now = new Date() }) { return [ `${member.display_name}님의 오늘 브리핑`, "", - `시간표: ${compactTimetable(timetable)}`, + `시간표: ${compactTimetable(timetableBundle)}`, "", "오늘·임박 일정", compactEvents(events, now), diff --git a/apps/classbot/server/services/chatbot-read-features.test.js b/apps/classbot/server/services/chatbot-read-features.test.js index b014b0e..9f6e603 100644 --- a/apps/classbot/server/services/chatbot-read-features.test.js +++ b/apps/classbot/server/services/chatbot-read-features.test.js @@ -80,7 +80,7 @@ test("오늘 브리핑은 개인 시간표·3일 이내 일정·최신 공지와 const { store, member } = fixture(); store.memberTimetable = [{ id: "personal-timetable", class_id: store.classroom.id, member_id: member.id, - weekday: 3, period: 1, subject: "개인수학", activity: "심화", teacher: "", room: "", memo: "", + weekday: 3, period: 1, subject: "수Ⅰ", activity: "심화", teacher: "류상욱", room: "", memo: "", effective_from: "2026-03-01", effective_to: null, }]; await store.createEvent({ member_id: member.id, subject: "영어", title: "개인 과제", due_at: "2026-07-16T18:00:00+09:00" }); @@ -97,7 +97,7 @@ test("오늘 브리핑은 개인 시간표·3일 이내 일정·최신 공지와 const briefing = await buildTodayBriefing({ store, member, now }); assert.match(briefing, /홍길동님의 오늘 브리핑/); - assert.match(briefing, /1교시 개인수학/); + assert.match(briefing, /1교시 수학\(류상욱T\)/); assert.match(briefing, /개인 과제 \(내일\)/); assert.doesNotMatch(briefing, /타인 과제|나흘 뒤 일정/); assert.match(briefing, /새 공지: 최신 공지/); diff --git a/apps/classbot/server/services/commands.js b/apps/classbot/server/services/commands.js index c19ef5b..0315285 100644 --- a/apps/classbot/server/services/commands.js +++ b/apps/classbot/server/services/commands.js @@ -7,8 +7,8 @@ import { textCardResponse, } from "./kakao.js"; import { + formatDayTimetable, formatEventRows, - formatTimetableRows, formatWeekTimetable, getDaySchedule, getUpcomingEvents, @@ -105,8 +105,53 @@ function readIntent(command) { return null; } -function readPeriod(command, intent) { +function explicitDatePeriod(command, now) { + const text = normalizedText(command); + const currentYear = getSeoulParts(now).year; + const patterns = [ + /(?:(\d{4})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/u, + /(?:^|\s)(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?=\s|$|시간표)/u, + /(?:^|\s)(\d{1,2})[/.](\d{1,2})(?=\s|$|시간표)/u, + ]; + let year; + let month; + let day; + const korean = text.match(patterns[0]); + if (korean) { + year = Number(korean[1] || currentYear); + month = Number(korean[2]); + day = Number(korean[3]); + } else { + const full = text.match(patterns[1]); + if (full) { + year = Number(full[1]); + month = Number(full[2]); + day = Number(full[3]); + } else { + const short = text.match(patterns[2]); + if (!short) return null; + year = currentYear; + month = Number(short[1]); + day = Number(short[2]); + } + } + if (year < 2000 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) { + throw new Error("조회할 날짜가 올바르지 않습니다. 예: ‘9월 23일 시간표’"); + } + const dateKey = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const target = new Date(`${dateKey}T12:00:00+09:00`); + if (Number.isNaN(target.getTime()) || getSeoulParts(target).dateKey !== dateKey) { + throw new Error("조회할 날짜가 올바르지 않습니다. 예: ‘9월 23일 시간표’"); + } + return { kind: "date", target }; +} + +function readPeriod(command, intent, now = new Date()) { const compact = compactText(command); + if (intent === "timetable") { + const explicit = explicitDatePeriod(command, now); + if (explicit) return explicit; + } if (intent === "timetable" && (compact.includes("전체") || compact.includes("월금"))) { return { kind: "full-timetable", label: "전체" }; } @@ -411,13 +456,13 @@ async function answerPendingFileConfirmation({ command, requester, store, makeFi } async function answerReadQuery({ command, intent, member, store, now, quickReplies, privateAccess = false }) { - const period = readPeriod(command, intent); + const period = readPeriod(command, intent, now); const replies = quickReplies || personalizedQuickReplies(member.display_name); const targetMemberId = privateAccess ? member.id : undefined; if (intent === "timetable") { if (period.kind === "full-timetable") { - const bundle = await getWeekTimetable(store, now, { targetMemberId }); + const bundle = await getWeekTimetable(store, now, { targetMemberId, respectAcademicCalendar: false }); return simpleTextResponse( `${member.display_name}님의 월~금 전체 시간표\n\n${formatWeekTimetable(bundle)}`, replies, @@ -430,10 +475,10 @@ async function answerReadQuery({ command, intent, member, store, now, quickRepli replies, ); } - const target = dateForSeoulOffset(now, period.offset); + const target = period.kind === "date" ? period.target : dateForSeoulOffset(now, period.offset); const bundle = await getDaySchedule(store, target, { targetMemberId }); return simpleTextResponse( - `${member.display_name}님의 ${formatKoreanDate(target)} 시간표\n\n${formatTimetableRows(bundle.timetable)}`, + `${member.display_name}님의 ${formatKoreanDate(target)} 시간표\n\n${formatDayTimetable(bundle)}`, replies, ); } diff --git a/apps/classbot/server/services/commands.test.js b/apps/classbot/server/services/commands.test.js index 95847f2..29a70b8 100644 --- a/apps/classbot/server/services/commands.test.js +++ b/apps/classbot/server/services/commands.test.js @@ -369,6 +369,32 @@ test("시간표도 이름을 맨 뒤에 요구하며 요청자 가입 없이 일 assert.match(text(nextWeek), /7월 20일 월요일/); }); +test("특정 날짜 시간표를 연도 포함·월일·슬래시 형식으로 조회하고 잘못된 날짜는 거부한다", async () => { + const store = fixture(); + for (const utterance of ["2026년 9월 23일 시간표 홍길동", "9월 23일 시간표 홍길동", "9/23 시간표 홍길동"]) { + const response = await ask(store, utterance); + assert.match(text(response), /홍길동님의 9월 23일 수요일 시간표/); + assert.match(text(response), /학사일정: 금요일 수업 · 퇴사/); + } + assert.match(text(await ask(store, "2월 30일 시간표 홍길동")), /날짜가 올바르지 않습니다/); +}); + +test("카카오 시간표 응답은 과목명을 통일하고 선생님을 과목명 뒤에 T로 표시한다", async () => { + const store = fileFixture(); + await store.replaceMemberTimetable({ + memberId: store.members[0].id, + rows: [ + { weekday: 3, period: 1, subject: "수Ⅰ", teacher: "류상욱", effective_from: "2026-07-01" }, + { weekday: 3, period: 2, subject: "공2", teacher: "공3", effective_from: "2026-07-01" }, + ], + }); + + const response = await ask(store, "오늘 시간표", { userId: "joined-hong" }); + assert.match(text(response), /1교시 수학\(류상욱T\)/); + assert.match(text(response), /2교시 공강/); + assert.doesNotMatch(text(response), /공2|공3T/); +}); + test("이름 누락·후행 규칙 위반·미등록·동명이인을 명확히 안내한다", async () => { const store = fixture(); assert.match(text(await ask(store, "오늘 일정")), /이름.*맨 뒤/); diff --git a/apps/classbot/server/services/event-policy.js b/apps/classbot/server/services/event-policy.js new file mode 100644 index 0000000..16be7ae --- /dev/null +++ b/apps/classbot/server/services/event-policy.js @@ -0,0 +1,6 @@ +export const ASSESSMENT_REMINDER_OFFSETS = Object.freeze([10080, 2880, 1440, 0]); +export const DEFAULT_REMINDER_OFFSETS = Object.freeze([4320, 1440, 0]); + +export function defaultReminderOffsets(category) { + return [...(category === "assessment" ? ASSESSMENT_REMINDER_OFFSETS : DEFAULT_REMINDER_OFFSETS)]; +} diff --git a/apps/classbot/server/services/notifications.test.js b/apps/classbot/server/services/notifications.test.js index 3e31d42..078aa07 100644 --- a/apps/classbot/server/services/notifications.test.js +++ b/apps/classbot/server/services/notifications.test.js @@ -140,6 +140,28 @@ test("개인 일정 알림은 대상 한 명에게만, 반 전체 일정은 활 ); }); +test("수행평가 기본 알림은 D-7·D-2·D-1·당일에 각각 예약된다", async () => { + const store = new MemoryStore(config); + activateTwo(store); + store.events = []; + const dueAt = new Date("2026-08-08T12:00:00+09:00"); + const event = await store.createEvent({ category: "assessment", title: "수학 수행평가", due_at: dueAt.toISOString() }); + assert.deepEqual(event.reminder_offsets, [10080, 2880, 1440, 0]); + const service = new NotificationService({ store, kakaoClient: { enabled: false } }); + const checks = [ + [7, /D-7/], + [2, /D-2/], + [1, /D-1/], + [0, /오늘 마감/], + ]; + for (const [days, label] of checks) { + const now = new Date(dueAt.getTime() - days * 86_400_000); + const batch = (await service.buildDueBatches(now)).find((item) => item.eventId === event.id); + assert.ok(batch); + assert.match(batch.message, label); + } +}); + test("아침 시간표 요약은 반 전체와 본인 개인 일정만 포함해 구성원별로 만든다", async () => { const store = new MemoryStore(config); activateTwo(store); @@ -162,6 +184,20 @@ test("아침 시간표 요약은 반 전체와 본인 개인 일정만 포함해 assert.doesNotMatch(second, /홍길동 준비/); }); +test("공휴일 아침 알림은 정규 시간표 대신 학사일정을 안내한다", async () => { + const store = new MemoryStore(config); + activateTwo(store); + store.events = []; + const service = new NotificationService({ store, kakaoClient: { enabled: false } }); + const now = new Date("2026-09-24T07:00:00+09:00"); + const daily = (await service.buildDueBatches(now)).filter((batch) => batch.kind === "daily_digest"); + + assert.equal(daily.length, 2); + assert.match(daily[0].message, /학사일정: 추석 연휴/); + assert.match(daily[0].message, /정규 수업이 없습니다/); + assert.doesNotMatch(daily[0].message, /1교시/); +}); + test("taskId 없는 오래된 reserved 알림을 orphan으로 노출하고 failed로 전환한다", async () => { const store = new MemoryStore(config); activateTwo(store); diff --git a/apps/classbot/server/services/schedule.js b/apps/classbot/server/services/schedule.js index 44000d3..8e57531 100644 --- a/apps/classbot/server/services/schedule.js +++ b/apps/classbot/server/services/schedule.js @@ -6,6 +6,12 @@ import { getSeoulParts, startOfSeoulDay, } from "../time.js"; +import { + applyTimetablePolicy, + formatTimetableSubject, + getAcademicDayPolicy, + normalizeTimetableRow, +} from "./timetable-policy.js"; const CATEGORY_LABELS = { assessment: "수행평가", @@ -27,18 +33,32 @@ async function timetableForMemberOrClass(store, { targetMemberId, weekday, date return store.listTimetable({ weekday }); } -export async function getDaySchedule(store, date = new Date(), { targetMemberId } = {}) { +export async function getTimetableForDate( + store, + date = new Date(), + { targetMemberId, respectAcademicCalendar = true } = {}, +) { const parts = getSeoulParts(date); - const timetable = parts.weekday >= 1 && parts.weekday <= 5 - ? await timetableForMemberOrClass(store, { targetMemberId, weekday: parts.weekday, date: parts.dateKey }) + const academicDay = respectAcademicCalendar ? getAcademicDayPolicy(parts.dateKey) : null; + const timetableWeekday = academicDay?.timetableWeekday ?? parts.weekday; + const rawTimetable = !academicDay?.noRegularClasses && timetableWeekday >= 1 && timetableWeekday <= 5 + ? await timetableForMemberOrClass(store, { targetMemberId, weekday: timetableWeekday, date: parts.dateKey }) : []; + const timetable = academicDay?.noRegularClasses + ? [] + : applyTimetablePolicy(rawTimetable, { dateKey: parts.dateKey, timetableWeekday }); + return { date, parts, timetable, academicDay, timetableWeekday }; +} + +export async function getDaySchedule(store, date = new Date(), { targetMemberId } = {}) { + const timetableBundle = await getTimetableForDate(store, date, { targetMemberId }); const events = await store.listEvents({ from: startOfSeoulDay(date).toISOString(), to: endOfSeoulDay(date).toISOString(), status: "scheduled", targetMemberId, }); - return { date, parts, timetable, events }; + return { ...timetableBundle, events }; } export function getWeekBounds(date = new Date(), weekOffset = 0) { @@ -68,21 +88,18 @@ export async function getUpcomingEvents(store, date = new Date(), days = 30, { t }); } -export async function getWeekTimetable(store, date = new Date(), { weekOffset = 0, targetMemberId } = {}) { +export async function getWeekTimetable( + store, + date = new Date(), + { weekOffset = 0, targetMemberId, respectAcademicCalendar = true } = {}, +) { const { monday } = getWeekBounds(date, weekOffset); - const [classRows, memberRows] = await Promise.all([ - store.listTimetable(), - targetMemberId && typeof store.listMemberTimetable === "function" - ? store.listMemberTimetable(targetMemberId, { date: getSeoulParts(monday).dateKey }) - : Promise.resolve([]), - ]); - const days = Array.from({ length: 5 }, (_, index) => ({ - date: dateForSeoulOffset(monday, index), - weekday: index + 1, - rows: (() => { - const personal = memberRows.filter((row) => row.weekday === index + 1); - return personal.length ? personal : classRows.filter((row) => row.weekday === index + 1); - })(), + const days = await Promise.all(Array.from({ length: 5 }, async (_, index) => { + const day = await getTimetableForDate(store, dateForSeoulOffset(monday, index), { + targetMemberId, + respectAcademicCalendar, + }); + return { ...day, weekday: index + 1, rows: day.timetable }; })); return { monday, days }; } @@ -91,15 +108,26 @@ export function formatTimetableRows(rows) { if (!rows.length) return "등록된 수업이 없습니다."; return rows .map((row) => { - const detail = [row.activity, row.room].filter(Boolean).join(" · "); - return `${row.period}교시 ${row.subject}${detail ? ` — ${detail}` : ""}${row.memo ? `\n 준비: ${row.memo}` : ""}`; + const normalized = normalizeTimetableRow(row); + const detail = [normalized.activity, normalized.room].filter(Boolean).join(" · "); + return `${normalized.period}교시 ${formatTimetableSubject(normalized)}${detail ? ` — ${detail}` : ""}${normalized.memo ? `\n 준비: ${normalized.memo}` : ""}`; }) .join("\n"); } +export function formatDayTimetable(bundle) { + const timetable = formatTimetableRows(bundle.timetable || bundle.rows || []); + if (!bundle.academicDay) return timetable; + const notice = `학사일정: ${bundle.academicDay.label}`; + if (bundle.academicDay.noRegularClasses) { + return `${notice}\n${bundle.academicDay.emptyText || "정규 수업이 없습니다."}`; + } + return `${notice}\n${timetable}`; +} + export function formatWeekTimetable(bundle) { return bundle.days - .map((day) => `${formatKoreanDate(day.date)}\n${formatTimetableRows(day.rows)}`) + .map((day) => `${formatKoreanDate(day.date)}\n${formatDayTimetable(day)}`) .join("\n\n"); } @@ -118,7 +146,7 @@ export function formatEventRows(events, baseDate = new Date(), emptyText = "예 export function buildDailyDigestText(bundle) { const title = `[Quilo] ${formatKoreanDate(bundle.date)}`; - const timetable = formatTimetableRows(bundle.timetable); + const timetable = formatDayTimetable(bundle); const events = bundle.events.length ? `\n\n오늘 마감\n${formatEventRows(bundle.events, bundle.date)}` : ""; return `${title}\n\n${timetable}${events}`; } diff --git a/apps/classbot/server/services/schedule.test.js b/apps/classbot/server/services/schedule.test.js index 159c567..fa36a2d 100644 --- a/apps/classbot/server/services/schedule.test.js +++ b/apps/classbot/server/services/schedule.test.js @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { MemoryStore } from "../store/memory-store.js"; -import { getDaySchedule, getWeekTimetable } from "./schedule.js"; +import { formatDayTimetable, formatTimetableRows, getDaySchedule, getWeekTimetable } from "./schedule.js"; +import { applyTimetablePolicy, normalizeTimetableRow } from "./timetable-policy.js"; const config = { classCode: "2-4", className: "2학년 4반", timezone: "Asia/Seoul" }; const wednesday = new Date("2026-07-15T03:00:00.000Z"); @@ -73,3 +74,97 @@ test("개인 시간표 교체는 중복 교시와 잘못된 적용 기간을 거 /종료일/, ); }); + +test("카카오 시간표 과목명과 담당 선생님 표기를 요청한 형식으로 통일한다", () => { + const rows = [ + { period: 1, subject: "수Ⅰ", teacher: "류상욱", room: "601호" }, + { period: 2, subject: "수2", teacher: "강윤석" }, + { period: 3, subject: "수Ⅲ", teacher: "추철우" }, + { period: 4, subject: "독서", teacher: "윤소영" }, + { period: 5, subject: "영Ⅰ", teacher: "이계화" }, + { period: 6, subject: "영2", teacher: "김선옥" }, + { period: 7, subject: "체2", teacher: "이재욱" }, + { period: 8, subject: "확통1", teacher: "박진환" }, + { period: 9, subject: "공2", teacher: "공3", room: "잘못된 강의실" }, + ]; + + const text = formatTimetableRows(rows); + assert.match(text, /1교시 수학\(류상욱T\) — 601호/); + assert.match(text, /2교시 수학\(강윤석T\)/); + assert.match(text, /3교시 수학\(추철우T\)/); + assert.match(text, /4교시 국어\(윤소영T\)/); + assert.match(text, /5교시 영어\(이계화T\)/); + assert.match(text, /6교시 영어\(김선옥T\)/); + assert.match(text, /7교시 체육\(이재욱T\)/); + assert.match(text, /8교시 확통\(박진환T\)/); + assert.match(text, /9교시 공강/); + assert.doesNotMatch(text, /공3T|잘못된 강의실/); + assert.deepEqual(normalizeTimetableRow({ subject: "공1", teacher: "공3" }), { + subject: "공강", teacher: "", activity: "", room: "", + }); +}); + +test("화요일 창체·동아리와 수요일 자율연구 교시를 날짜 규칙으로 보정한다", () => { + const source = Array.from({ length: 9 }, (_, index) => ({ + weekday: 2, + period: index + 1, + subject: `원본${index + 1}`, + teacher: "원본교사", + })); + const regularTuesday = applyTimetablePolicy(source, { dateKey: "2026-09-29", timetableWeekday: 2 }); + const clubTuesday = applyTimetablePolicy(source, { dateKey: "2026-09-22", timetableWeekday: 2 }); + const wednesday = applyTimetablePolicy(source, { dateKey: "2026-09-02", timetableWeekday: 3 }); + + assert.deepEqual(regularTuesday.filter((row) => row.period >= 7).map((row) => row.subject), ["창체", "창체", "창체"]); + assert.deepEqual(clubTuesday.filter((row) => row.period >= 7).map((row) => row.subject), ["창체", "동아리", "동아리"]); + assert.deepEqual(wednesday.filter((row) => row.period >= 5 && row.period <= 7).map((row) => row.subject), ["자율연구", "자율연구", "자율연구"]); + assert.equal(wednesday.find((row) => row.period === 5).teacher, ""); +}); + +test("학사일정의 월·목·금요일 대체수업은 해당 요일의 개인 시간표를 사용한다", async () => { + const store = new MemoryStore(config); + const member = store.members[0]; + await store.replaceMemberTimetable({ + memberId: member.id, + rows: [ + { weekday: 1, period: 1, subject: "월요일 과목", effective_from: "2026-08-01" }, + { weekday: 3, period: 1, subject: "수요일 과목", effective_from: "2026-08-01" }, + { weekday: 4, period: 1, subject: "목요일 과목", effective_from: "2026-08-01" }, + { weekday: 5, period: 1, subject: "금요일 과목", effective_from: "2026-08-01" }, + ], + }); + + const mondayOnTuesday = await getDaySchedule(store, new Date("2026-08-18T12:00:00+09:00"), { targetMemberId: member.id }); + const fridayOnWednesday = await getDaySchedule(store, new Date("2026-09-23T12:00:00+09:00"), { targetMemberId: member.id }); + const thursdayOnMonday = await getDaySchedule(store, new Date("2026-11-09T12:00:00+09:00"), { targetMemberId: member.id }); + const thursdayInDecember = await getDaySchedule(store, new Date("2026-12-21T12:00:00+09:00"), { targetMemberId: member.id }); + + assert.equal(mondayOnTuesday.timetableWeekday, 1); + assert.equal(mondayOnTuesday.timetable[0].subject, "월요일 과목"); + assert.equal(fridayOnWednesday.timetableWeekday, 5); + assert.equal(fridayOnWednesday.timetable[0].subject, "금요일 과목"); + assert.equal(thursdayOnMonday.timetableWeekday, 4); + assert.equal(thursdayOnMonday.timetable[0].subject, "목요일 과목"); + assert.equal(thursdayInDecember.timetableWeekday, 4); + assert.equal(thursdayInDecember.timetable[0].subject, "목요일 과목"); + assert.match(formatDayTimetable(fridayOnWednesday), /학사일정: 금요일 수업 · 퇴사/); +}); + +test("공휴일·시험·수능·행사·방학식에는 정규 시간표를 반환하지 않는다", async () => { + const store = new MemoryStore(config); + const cases = [ + ["2026-09-24", "추석 연휴", "정규 수업이 없습니다"], + ["2026-10-13", "중간고사", "시험 일정이 적용됩니다"], + ["2026-11-19", "수능일", "정규 수업이 없습니다"], + ["2026-11-27", "술개한마당", "학사일정이 진행됩니다"], + ["2026-12-18", "재량휴업일", "정규 수업이 없습니다"], + ["2026-12-23", "1·2학년 방학식", "학사일정이 진행됩니다"], + ]; + + for (const [dateKey, label, emptyText] of cases) { + const bundle = await getDaySchedule(store, new Date(`${dateKey}T12:00:00+09:00`)); + assert.deepEqual(bundle.timetable, []); + assert.match(formatDayTimetable(bundle), new RegExp(label)); + assert.match(formatDayTimetable(bundle), new RegExp(emptyText)); + } +}); diff --git a/apps/classbot/server/services/timetable-policy.js b/apps/classbot/server/services/timetable-policy.js new file mode 100644 index 0000000..eae6ebc --- /dev/null +++ b/apps/classbot/server/services/timetable-policy.js @@ -0,0 +1,140 @@ +const CLUB_ACTIVITY_DATES = new Set([ + "2026-09-01", + "2026-09-08", + "2026-09-15", + "2026-09-22", + "2026-10-20", + "2026-10-27", + "2026-11-17", + "2026-11-24", +]); + +const ACADEMIC_DAY_POLICIES = new Map(Object.entries({ + "2026-08-17": closedDay("대체 공휴일 · 입사"), + "2026-08-18": replacementDay("개학 · 월요일 수업", 1), + "2026-09-23": replacementDay("금요일 수업 · 퇴사", 5), + "2026-09-24": closedDay("추석 연휴"), + "2026-09-25": closedDay("추석"), + "2026-09-26": closedDay("추석 연휴"), + "2026-10-03": closedDay("개천절"), + "2026-10-05": closedDay("대체 공휴일"), + "2026-10-09": closedDay("한글날"), + "2026-11-09": replacementDay("목요일 수업", 4), + "2026-11-19": closedDay("수능일"), + "2026-11-27": specialDay("술개한마당"), + "2026-12-18": closedDay("재량휴업일"), + "2026-12-21": replacementDay("목요일 수업 · 10시 입사 · 3학년 방학식", 4), + "2026-12-23": specialDay("1·2학년 방학식 · 퇴사"), + "2026-12-25": closedDay("크리스마스"), + "2026-12-28": specialDay("졸업식 · 겨울방학"), + "2027-01-01": closedDay("신정"), +})); + +const NO_REGULAR_CLASS_RANGES = [ + { from: "2026-08-01", to: "2026-08-16", policy: closedDay("여름방학", "방학 중에는 정규 수업이 없습니다.") }, + { from: "2026-10-13", to: "2026-10-16", policy: examDay("중간고사") }, + { from: "2026-12-14", to: "2026-12-16", policy: examDay("기말고사") }, + { from: "2026-12-17", to: "2026-12-17", policy: examDay("기말고사 · 성적 확인 · 퇴사") }, + { from: "2026-12-24", to: "2027-01-02", policy: closedDay("겨울방학", "방학 중에는 정규 수업이 없습니다.") }, +]; + +function closedDay(label, emptyText = "정규 수업이 없습니다.") { + return { label, noRegularClasses: true, emptyText }; +} + +function specialDay(label) { + return { label, noRegularClasses: true, emptyText: "정규 시간표 대신 학사일정이 진행됩니다." }; +} + +function examDay(label) { + return { label, noRegularClasses: true, emptyText: "정규 시간표 대신 시험 일정이 적용됩니다." }; +} + +function replacementDay(label, timetableWeekday) { + return { label, noRegularClasses: false, timetableWeekday }; +} + +function inDateRange(dateKey, { from, to }) { + return dateKey >= from && dateKey <= to; +} + +export function getAcademicDayPolicy(dateKey) { + const normalized = String(dateKey || "").trim(); + const explicit = ACADEMIC_DAY_POLICIES.get(normalized); + if (explicit) return { ...explicit }; + const range = NO_REGULAR_CLASS_RANGES.find((item) => inDateRange(normalized, item)); + return range ? { ...range.policy } : null; +} + +function normalizedSubject(value) { + const subject = String(value || "").trim().replace(/\s+/g, ""); + if (/^수(?:1|2|3|Ⅰ|Ⅱ|Ⅲ)$/u.test(subject)) return "수학"; + if (subject === "독서") return "국어"; + if (/^영(?:1|2|Ⅰ|Ⅱ)$/u.test(subject)) return "영어"; + if (/^체(?:2|Ⅱ)$/u.test(subject)) return "체육"; + if (/^확통(?:1|Ⅰ)$/u.test(subject)) return "확통"; + if (/^공(?:강|1|2|3|Ⅰ|Ⅱ|Ⅲ)$/u.test(subject)) return "공강"; + return String(value || "").trim(); +} + +export function normalizeTimetableRow(row = {}) { + const subject = normalizedSubject(row.subject); + return { + ...row, + subject, + teacher: subject === "공강" ? "" : String(row.teacher || "").trim(), + activity: subject === "공강" ? "" : String(row.activity || "").trim(), + room: subject === "공강" ? "" : String(row.room || "").trim(), + }; +} + +function replacePeriods(rows, timetableWeekday, replacements) { + const byPeriod = new Map(rows.map((row) => [Number(row.period), row])); + for (const [period, subject] of replacements) { + const current = byPeriod.get(period) || { period, weekday: timetableWeekday }; + byPeriod.set(period, { + ...current, + weekday: timetableWeekday, + period, + subject, + teacher: "", + activity: "", + room: "", + memo: "", + derived_timetable_rule: true, + }); + } + return [...byPeriod.values()].sort((a, b) => Number(a.period) - Number(b.period)); +} + +export function applyTimetablePolicy(rows, { dateKey, timetableWeekday }) { + let result = (Array.isArray(rows) ? rows : []).map(normalizeTimetableRow); + + if (Number(timetableWeekday) === 3) { + // 후속 자율연구 일정이 제공되면 외출 여부를 자율연구(O)/자율연구(X)로 반영한다. + // 현재는 외출 여부를 추정하지 않고 5~7교시를 자율연구로만 표시한다. + result = replacePeriods(result, 3, [[5, "자율연구"], [6, "자율연구"], [7, "자율연구"]]); + } + + if (Number(timetableWeekday) === 2) { + const replacements = [[7, "창체"], [8, "창체"], [9, "창체"]]; + if (CLUB_ACTIVITY_DATES.has(String(dateKey || ""))) { + replacements[1][1] = "동아리"; + replacements[2][1] = "동아리"; + } + result = replacePeriods(result, 2, replacements); + } + + return result; +} + +export function formatTimetableSubject(row) { + const normalized = normalizeTimetableRow(row); + if (!normalized.teacher) return normalized.subject; + const teacher = /T$/iu.test(normalized.teacher) ? normalized.teacher : `${normalized.teacher}T`; + return `${normalized.subject}(${teacher})`; +} + +export function isClubActivityDate(dateKey) { + return CLUB_ACTIVITY_DATES.has(String(dateKey || "")); +} diff --git a/apps/classbot/server/services/virtual-market.js b/apps/classbot/server/services/virtual-market.js new file mode 100644 index 0000000..c3e2a77 --- /dev/null +++ b/apps/classbot/server/services/virtual-market.js @@ -0,0 +1,139 @@ +import { getSeoulParts } from "../time.js"; + +export const STARTING_TOKENS = 1000; +export const DAILY_REWARD_TOKENS = 100; + +export const VIRTUAL_INSTRUMENTS = Object.freeze([ + { symbol: "QLR", name: "퀼로랩", basePrice: 126, tone: "blue" }, + { symbol: "BLW", name: "블루웨이브", basePrice: 98, tone: "slate" }, + { symbol: "NXT", name: "넥스트셀", basePrice: 143, tone: "green" }, + { symbol: "GCR", name: "그린코어", basePrice: 72, tone: "teal" }, + { symbol: "SPW", name: "스파크웍스", basePrice: 57, tone: "violet" }, +]); + +const INSTRUMENT_BY_SYMBOL = new Map(VIRTUAL_INSTRUMENTS.map((item) => [item.symbol, item])); +const DAY_MS = 86_400_000; +const MARKET_EPOCH = Date.UTC(2026, 0, 1); + +function hashNumber(value) { + let hash = 2166136261; + for (const char of String(value)) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function dateKeyFor(value) { + return getSeoulParts(value instanceof Date ? value : new Date(value)).dateKey; +} + +function dayNumber(dateKey) { + return Math.floor((Date.parse(`${dateKey}T00:00:00Z`) - MARKET_EPOCH) / DAY_MS); +} + +function dateKeyWithOffset(dateKey, offset) { + const date = new Date(`${dateKey}T12:00:00+09:00`); + return getSeoulParts(new Date(date.getTime() + offset * DAY_MS)).dateKey; +} + +export function virtualPrice(symbol, at = new Date()) { + const instrument = INSTRUMENT_BY_SYMBOL.get(String(symbol || "").toUpperCase()); + if (!instrument) throw new Error("존재하지 않는 가상 종목입니다."); + const dateKey = dateKeyFor(at); + const day = dayNumber(dateKey); + const seed = hashNumber(instrument.symbol) % 31; + const wave = Math.sin((day + seed) * 0.19) * instrument.basePrice * 0.055; + const longWave = Math.cos((day + seed * 3) * 0.047) * instrument.basePrice * 0.035; + const noise = ((hashNumber(`${instrument.symbol}:${dateKey}`) % 9) - 4) * 0.65; + return Math.max(10, Math.round(instrument.basePrice + wave + longWave + noise)); +} + +export function validateVirtualOrder(input) { + const symbol = String(input?.symbol || "").trim().toUpperCase(); + const side = String(input?.side || "").trim().toLowerCase(); + const quantity = Number(input?.quantity); + if (!INSTRUMENT_BY_SYMBOL.has(symbol)) throw new Error("존재하지 않는 가상 종목입니다."); + if (!new Set(["buy", "sell"]).has(side)) throw new Error("매수 또는 매도를 선택해 주세요."); + if (!Number.isInteger(quantity) || quantity < 1 || quantity > 1000) { + throw new Error("주문 수량은 1~1000주 사이의 정수여야 합니다."); + } + return { symbol, side, quantity }; +} + +function priceHistory(symbol, dateKey, days = 21) { + return Array.from({ length: days }, (_, index) => { + const key = dateKeyWithOffset(dateKey, index - days + 1); + return { date: key, price: virtualPrice(symbol, new Date(`${key}T12:00:00+09:00`)) }; + }); +} + +export async function marketSnapshot(store, memberId, at = new Date()) { + const dateKey = dateKeyFor(at); + const state = await store.getMarketState(memberId); + const positionsBySymbol = new Map((state.positions || []).map((item) => [item.symbol, item])); + const instruments = VIRTUAL_INSTRUMENTS.map((instrument) => { + const price = virtualPrice(instrument.symbol, at); + const previousKey = dateKeyWithOffset(dateKey, -1); + const previousPrice = virtualPrice(instrument.symbol, new Date(`${previousKey}T12:00:00+09:00`)); + const position = positionsBySymbol.get(instrument.symbol); + const quantity = Number(position?.quantity || 0); + const averageCost = Number(position?.average_cost || 0); + return { + ...instrument, + price, + change: price - previousPrice, + change_percent: previousPrice ? Number((((price - previousPrice) / previousPrice) * 100).toFixed(2)) : 0, + owned_quantity: quantity, + average_cost: averageCost, + market_value: quantity * price, + profit_loss: quantity * (price - averageCost), + history: priceHistory(instrument.symbol, dateKey), + }; + }); + const positions = instruments.filter((item) => item.owned_quantity > 0); + const marketValue = positions.reduce((sum, item) => sum + item.market_value, 0); + const costBasis = positions.reduce((sum, item) => sum + item.average_cost * item.owned_quantity, 0); + const profitLoss = marketValue - costBasis; + const balance = Number(state.account?.balance ?? STARTING_TOKENS); + const rewardKey = `daily:${dateKey}`; + return { + as_of: dateKey, + currency: "TKN", + play_money_only: true, + reward: { + amount: DAILY_REWARD_TOKENS, + claimed_today: (state.ledger || []).some((entry) => entry.reference_key === rewardKey), + }, + account: { + balance, + market_value: marketValue, + total_assets: balance + marketValue, + profit_loss: profitLoss, + return_percent: costBasis ? Number(((profitLoss / costBasis) * 100).toFixed(2)) : 0, + }, + instruments, + positions, + activity: (state.ledger || []).slice(0, 20), + trades: (state.trades || []).slice(0, 20), + }; +} + +export async function claimMarketReward(store, memberId, at = new Date()) { + const dateKey = dateKeyFor(at); + await store.claimDailyMarketReward({ + memberId, + dateKey, + amount: DAILY_REWARD_TOKENS, + }); + return marketSnapshot(store, memberId, at); +} + +export async function executeMarketOrder(store, memberId, input, at = new Date()) { + const order = validateVirtualOrder(input); + const requestKey = String(input?.requestKey || "").trim(); + if (!requestKey || requestKey.length > 160) throw new Error("안전한 주문 처리를 위해 Idempotency-Key가 필요합니다."); + const price = virtualPrice(order.symbol, at); + await store.executeMarketTrade({ memberId, ...order, price, requestKey }); + return marketSnapshot(store, memberId, at); +} diff --git a/apps/classbot/server/services/virtual-market.test.js b/apps/classbot/server/services/virtual-market.test.js new file mode 100644 index 0000000..6058b6e --- /dev/null +++ b/apps/classbot/server/services/virtual-market.test.js @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { MemoryStore } from "../store/memory-store.js"; +import { claimMarketReward, executeMarketOrder, marketSnapshot, virtualPrice } from "./virtual-market.js"; + +const config = { classCode: "2-4", className: "2학년 4반", timezone: "Asia/Seoul" }; +const at = new Date("2026-08-01T12:00:00+09:00"); + +test("가상 시세는 허구 종목과 날짜에 대해 결정적이며 양수이다", () => { + assert.equal(virtualPrice("QLR", at), virtualPrice("QLR", at)); + assert.ok(virtualPrice("QLR", at) > 0); + assert.throws(() => virtualPrice("AAPL", at), /존재하지 않는 가상 종목/); +}); + +test("접속 보상은 하루 한 번만 지급되고 매수·매도는 잔액과 보유량을 원자적으로 바꾼다", async () => { + const store = new MemoryStore(config); + const memberId = store.members[0].id; + let snapshot = await marketSnapshot(store, memberId, at); + assert.equal(snapshot.account.balance, 1000); + assert.equal(snapshot.reward.claimed_today, false); + + snapshot = await claimMarketReward(store, memberId, at); + assert.equal(snapshot.account.balance, 1100); + assert.equal(snapshot.reward.claimed_today, true); + snapshot = await claimMarketReward(store, memberId, at); + assert.equal(snapshot.account.balance, 1100); + + const price = virtualPrice("QLR", at); + snapshot = await executeMarketOrder(store, memberId, { symbol: "QLR", side: "buy", quantity: 2, requestKey: "order-1" }, at); + assert.equal(snapshot.account.balance, 1100 - price * 2); + assert.equal(snapshot.positions.find((item) => item.symbol === "QLR").owned_quantity, 2); + + snapshot = await executeMarketOrder(store, memberId, { symbol: "QLR", side: "buy", quantity: 2, requestKey: "order-1" }, at); + assert.equal(snapshot.positions.find((item) => item.symbol === "QLR").owned_quantity, 2); + + snapshot = await executeMarketOrder(store, memberId, { symbol: "QLR", side: "sell", quantity: 1, requestKey: "order-2" }, at); + assert.equal(snapshot.positions.find((item) => item.symbol === "QLR").owned_quantity, 1); + assert.equal(snapshot.account.balance, 1100 - price); +}); + +test("잔액보다 큰 매수와 보유량보다 큰 매도는 거부한다", async () => { + const store = new MemoryStore(config); + const memberId = store.members[0].id; + await assert.rejects( + executeMarketOrder(store, memberId, { symbol: "QLR", side: "buy", quantity: 1000, requestKey: "too-big" }, at), + /토큰이 부족/, + ); + await assert.rejects( + executeMarketOrder(store, memberId, { symbol: "QLR", side: "sell", quantity: 1, requestKey: "no-stock" }, at), + /보유 수량이 부족/, + ); +}); diff --git a/apps/classbot/server/store/memory-store.js b/apps/classbot/server/store/memory-store.js index 590ce47..85c07ab 100644 --- a/apps/classbot/server/store/memory-store.js +++ b/apps/classbot/server/store/memory-store.js @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { getSeoulParts, parseKoreaDateTime, startOfSeoulDay } from "../time.js"; import { hashInviteCode } from "../security.js"; +import { defaultReminderOffsets } from "../services/event-policy.js"; const clone = (value) => structuredClone(value); @@ -172,7 +173,7 @@ function seedEvents(classId) { description: "실험 결과와 오차 분석을 포함해 제출", due_at: at(1), status: "scheduled", - reminder_offsets: [4320, 1440, 0], + reminder_offsets: [10080, 2880, 1440, 0], notify_on_change: true, created_by: "demo-admin", created_at: nowIso(), @@ -188,7 +189,7 @@ function seedEvents(classId) { description: "3분 개인 발표", due_at: at(3, 9, 0), status: "scheduled", - reminder_offsets: [4320, 1440, 0], + reminder_offsets: [10080, 2880, 1440, 0], notify_on_change: true, created_by: "demo-admin", created_at: nowIso(), @@ -267,6 +268,16 @@ export class MemoryStore { this.fileBodies = new Map(); this.kakaoStates = new Map(); this.kakaoActionStates = new Map(); + this.marketAccounts = new Map(this.members.map((member) => [member.id, { + class_id: this.classroom.id, + member_id: member.id, + balance: 1000, + created_at: nowIso(), + updated_at: nowIso(), + }])); + this.marketPositions = []; + this.marketTrades = []; + this.marketLedger = []; } async healthCheck() { @@ -617,7 +628,11 @@ export class MemoryStore { description: String(input.description || "").trim(), due_at: dueAt.toISOString(), status: "scheduled", - reminder_offsets: Array.isArray(input.reminder_offsets) ? input.reminder_offsets.map(Number) : [4320, 1440, 0], + reminder_offsets: (input.category || "assessment") === "assessment" + ? defaultReminderOffsets("assessment") + : Array.isArray(input.reminder_offsets) + ? input.reminder_offsets.map(Number) + : defaultReminderOffsets(input.category), notify_on_change: input.notify_on_change !== false, request_key: input.request_key || null, created_by: actor, @@ -642,7 +657,8 @@ export class MemoryStore { if (patch[key] !== undefined) event[key] = patch[key]; } if (patch.due_at !== undefined) event.due_at = parseKoreaDateTime(patch.due_at).toISOString(); - if (patch.reminder_offsets !== undefined) event.reminder_offsets = patch.reminder_offsets.map(Number); + if (event.category === "assessment") event.reminder_offsets = defaultReminderOffsets("assessment"); + else if (patch.reminder_offsets !== undefined) event.reminder_offsets = patch.reminder_offsets.map(Number); event.updated_at = nowIso(); await this.appendAudit({ actor, action: "event.update", entityType: "event", entityId: event.id, before, after: event }); return clone(event); @@ -803,6 +819,113 @@ export class MemoryStore { return Buffer.from(body); } + async getMarketState(memberId) { + const member = this.members.find((item) => item.id === memberId && item.class_id === this.classroom.id && item.status !== "left"); + if (!member) throw new Error("가상 주식 계정을 조회할 구성원을 찾을 수 없습니다."); + if (!this.marketAccounts.has(memberId)) { + this.marketAccounts.set(memberId, { + class_id: this.classroom.id, + member_id: memberId, + balance: 1000, + created_at: nowIso(), + updated_at: nowIso(), + }); + } + return clone({ + account: this.marketAccounts.get(memberId), + positions: this.marketPositions.filter((item) => item.member_id === memberId && item.quantity > 0), + trades: this.marketTrades + .filter((item) => item.member_id === memberId) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)), + ledger: this.marketLedger + .filter((item) => item.member_id === memberId) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)), + }); + } + + async claimDailyMarketReward({ memberId, dateKey, amount }) { + await this.getMarketState(memberId); + const referenceKey = `daily:${dateKey}`; + const existing = this.marketLedger.find((item) => item.member_id === memberId && item.reference_key === referenceKey); + if (existing) return clone({ claimed: false, ledger: existing }); + const account = this.marketAccounts.get(memberId); + account.balance += Number(amount); + account.updated_at = nowIso(); + const ledger = { + id: id("token"), + class_id: this.classroom.id, + member_id: memberId, + kind: "daily_reward", + amount: Number(amount), + balance_after: account.balance, + reference_key: referenceKey, + metadata: { reward_date: dateKey }, + created_at: nowIso(), + }; + this.marketLedger.push(ledger); + return clone({ claimed: true, ledger }); + } + + async executeMarketTrade({ memberId, symbol, side, quantity, price, requestKey }) { + await this.getMarketState(memberId); + const existing = this.marketTrades.find((item) => item.member_id === memberId && item.request_key === requestKey); + if (existing) return clone(existing); + const account = this.marketAccounts.get(memberId); + let position = this.marketPositions.find((item) => item.member_id === memberId && item.symbol === symbol); + if (!position) { + position = { + class_id: this.classroom.id, + member_id: memberId, + symbol, + quantity: 0, + average_cost: 0, + created_at: nowIso(), + updated_at: nowIso(), + }; + this.marketPositions.push(position); + } + const total = Number(quantity) * Number(price); + if (side === "buy") { + if (account.balance < total) throw new Error("보유 토큰이 부족해 매수할 수 없습니다."); + const nextQuantity = position.quantity + Number(quantity); + position.average_cost = Math.round((position.average_cost * position.quantity + total) / nextQuantity); + position.quantity = nextQuantity; + account.balance -= total; + } else { + if (position.quantity < Number(quantity)) throw new Error("보유 수량이 부족해 매도할 수 없습니다."); + position.quantity -= Number(quantity); + if (!position.quantity) position.average_cost = 0; + account.balance += total; + } + account.updated_at = nowIso(); + position.updated_at = nowIso(); + const trade = { + id: id("trade"), + class_id: this.classroom.id, + member_id: memberId, + symbol, + side, + quantity: Number(quantity), + price: Number(price), + total, + request_key: requestKey, + created_at: nowIso(), + }; + this.marketTrades.push(trade); + this.marketLedger.push({ + id: id("token"), + class_id: this.classroom.id, + member_id: memberId, + kind: side, + amount: side === "buy" ? -total : total, + balance_after: account.balance, + reference_key: `trade:${requestKey}`, + metadata: { trade_id: trade.id, symbol, quantity: Number(quantity), price: Number(price) }, + created_at: trade.created_at, + }); + return clone(trade); + } + async listNotifications({ limit = 50, status } = {}) { return clone(this.notifications) .filter((item) => !status || item.status === status) diff --git a/apps/classbot/server/store/schema-security.test.js b/apps/classbot/server/store/schema-security.test.js index 6980e89..f02cf2b 100644 --- a/apps/classbot/server/store/schema-security.test.js +++ b/apps/classbot/server/store/schema-security.test.js @@ -34,8 +34,8 @@ test("스키마 버전 health RPC와 전체 RLS가 운영 준비 상태를 검 assert.match(schema, /create table if not exists public\.classbot_schema_meta/); assert.match(schema, /create or replace function public\.classbot_health_check\(\)/); assert.match(schema, /grant execute on function public\.classbot_health_check\(\) to service_role/); - assert.match(schema, /values \(1, 6, now\(\)\)/); - for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "member_timetable", "events", "notices", "files", "kakao_states", "kakao_pending_actions", "notifications", "audit_logs"]) { + assert.match(schema, /values \(1, 7, now\(\)\)/); + for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "member_timetable", "events", "notices", "files", "kakao_states", "kakao_pending_actions", "notifications", "audit_logs", "token_accounts", "token_ledger", "market_positions", "market_trades"]) { assert.match(schema, new RegExp(`alter table public\\.classbot_${table} enable row level security`)); } assert.match(storeSource, /\.rpc\("classbot_health_check"\)/); @@ -51,7 +51,25 @@ test("개인별 시간표는 학급-구성원 복합 경계와 원자적 전체 assert.match(replaceFunction, /class_id = p_class_id[\s\S]*id = p_member_id[\s\S]*for update/); assert.match(replaceFunction, /delete from public\.classbot_member_timetable/); assert.match(storeSource, /\.rpc\("classbot_replace_member_timetable"/); - assert.match(storeSource, /Number\(version\) !== 6/); + assert.match(storeSource, /Number\(version\) !== 7/); +}); + +test("가상 주식은 RLS 테이블과 구성원별 lock 기반 RPC에서만 잔액·보유량을 변경한다", () => { + assert.match(schema, /update public\.classbot_events[\s\S]*category = 'assessment'[\s\S]*array\[10080, 2880, 1440, 0\]/); + assert.match(schema, /create trigger enforce_assessment_reminders_insert[\s\S]*classbot_enforce_assessment_reminders/); + assert.match(schema, /create trigger enforce_assessment_reminders_update[\s\S]*classbot_enforce_assessment_reminders/); + assert.match(schema, /create table if not exists public\.classbot_token_accounts/); + assert.match(schema, /create table if not exists public\.classbot_market_positions/); + assert.match(schema, /unique \(class_id, member_id, request_key\)/); + const reward = schema.match(/create or replace function public\.classbot_claim_daily_market_reward[\s\S]*?\$\$;/)?.[0] || ""; + assert.match(reward, /for update/); + assert.match(reward, /reference_key = reward_key/); + const trade = schema.match(/create or replace function public\.classbot_execute_market_trade[\s\S]*?\$\$;/)?.[0] || ""; + assert.match(trade, /for update/); + assert.match(trade, /보유 토큰이 부족/); + assert.match(trade, /보유 수량이 부족/); + assert.match(storeSource, /\.rpc\("classbot_claim_daily_market_reward"/); + assert.match(storeSource, /\.rpc\("classbot_execute_market_trade"/); }); test("이름 등록 RPC는 학급 lock과 정확 일치로 key 탈취·재바인딩을 막는다", () => { diff --git a/apps/classbot/server/store/supabase-store.js b/apps/classbot/server/store/supabase-store.js index a3c9cf6..2f05f6e 100644 --- a/apps/classbot/server/store/supabase-store.js +++ b/apps/classbot/server/store/supabase-store.js @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { createClient } from "@supabase/supabase-js"; import { hashInviteCode } from "../security.js"; import { getSeoulParts, parseKoreaDateTime } from "../time.js"; +import { defaultReminderOffsets } from "../services/event-policy.js"; function unwrap(result, message) { if (result.error) throw new Error(`${message}: ${result.error.message}`); @@ -153,7 +154,7 @@ export class SupabaseStore { async healthCheck() { await this.ensureClassroom(); const version = unwrap(await this.client.rpc("classbot_health_check"), "학급 저장소 상태 확인 실패"); - if (Number(version) !== 6) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); + if (Number(version) !== 7) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); return { ok: true, storage: "supabase" }; } @@ -593,7 +594,9 @@ export class SupabaseStore { title: String(input.title || "").trim(), description: String(input.description || "").trim(), due_at: parseKoreaDateTime(input.due_at).toISOString(), - reminder_offsets: input.reminder_offsets || [4320, 1440, 0], + reminder_offsets: (input.category || "assessment") === "assessment" + ? defaultReminderOffsets("assessment") + : input.reminder_offsets || defaultReminderOffsets(input.category), notify_on_change: input.notify_on_change !== false, request_key: input.request_key || null, created_by: actor, @@ -614,6 +617,8 @@ export class SupabaseStore { async updateEvent(eventId, patch, actor = "admin") { const classroom = await this.ensureClassroom(); + const current = await this.getEvent(eventId); + if (!current) throw new Error("일정을 찾을 수 없습니다."); if (patch.member_id) { const member = await this.getMember(patch.member_id); if (!member || member.status === "left") throw new Error("개인 일정을 등록할 구성원을 찾을 수 없습니다."); @@ -621,6 +626,9 @@ export class SupabaseStore { const allowed = Object.fromEntries( Object.entries(patch).filter(([key]) => ["member_id", "category", "subject", "title", "description", "status", "notify_on_change", "reminder_offsets"].includes(key)), ); + if ((patch.category ?? current.category) === "assessment") { + allowed.reminder_offsets = defaultReminderOffsets("assessment"); + } if (patch.due_at !== undefined) allowed.due_at = parseKoreaDateTime(patch.due_at).toISOString(); const event = unwrap( await this.client.from("classbot_events").update(allowed).eq("class_id", classroom.id).eq("id", eventId).select("*").single(), @@ -871,6 +879,55 @@ export class SupabaseStore { return Buffer.from(await downloaded.data.arrayBuffer()); } + async getMarketState(memberId) { + const classroom = await this.ensureClassroom(); + const member = await this.getMember(memberId); + if (!member || member.status === "left") throw new Error("가상 주식 계정을 조회할 구성원을 찾을 수 없습니다."); + const [accountResult, positionsResult, tradesResult, ledgerResult] = await Promise.all([ + this.client.from("classbot_token_accounts").select("*").eq("class_id", classroom.id).eq("member_id", memberId).maybeSingle(), + this.client.from("classbot_market_positions").select("*").eq("class_id", classroom.id).eq("member_id", memberId).gt("quantity", 0).order("symbol"), + this.client.from("classbot_market_trades").select("*").eq("class_id", classroom.id).eq("member_id", memberId).order("created_at", { ascending: false }).limit(50), + this.client.from("classbot_token_ledger").select("*").eq("class_id", classroom.id).eq("member_id", memberId).order("created_at", { ascending: false }).limit(50), + ]); + const account = unwrap(accountResult, "토큰 계정 조회 실패"); + if (!account) throw new Error("가상 주식 계정이 준비되지 않았습니다. 데이터베이스 마이그레이션을 확인해 주세요."); + return { + account, + positions: unwrap(positionsResult, "가상 주식 보유 현황 조회 실패"), + trades: unwrap(tradesResult, "가상 주식 거래 내역 조회 실패"), + ledger: unwrap(ledgerResult, "토큰 내역 조회 실패"), + }; + } + + async claimDailyMarketReward({ memberId, dateKey, amount }) { + const classroom = await this.ensureClassroom(); + return unwrap( + await this.client.rpc("classbot_claim_daily_market_reward", { + p_class_id: classroom.id, + p_member_id: memberId, + p_reward_date: dateKey, + p_amount: Number(amount), + }), + "접속 보상 지급 실패", + ); + } + + async executeMarketTrade({ memberId, symbol, side, quantity, price, requestKey }) { + const classroom = await this.ensureClassroom(); + return unwrap( + await this.client.rpc("classbot_execute_market_trade", { + p_class_id: classroom.id, + p_member_id: memberId, + p_symbol: symbol, + p_side: side, + p_quantity: Number(quantity), + p_price: Number(price), + p_request_key: requestKey, + }), + "가상 주식 주문 처리 실패", + ); + } + async listNotifications({ limit = 50, status } = {}) { const classroom = await this.ensureClassroom(); let query = this.client diff --git a/apps/classbot/server/store/supabase-store.test.js b/apps/classbot/server/store/supabase-store.test.js index 14215fb..44c8570 100644 --- a/apps/classbot/server/store/supabase-store.test.js +++ b/apps/classbot/server/store/supabase-store.test.js @@ -206,3 +206,34 @@ test("Supabase 스키마 오류는 연결 재시도 대상으로 숨기지 않 await assert.rejects(store.initialize(), /relation classbot_classes does not exist/); assert.equal(attempts, 1); }); + +test("가상 주식 보상과 주문은 학급·구성원 scope의 원자적 RPC만 호출한다", async () => { + const { store, calls } = fixture({ ok: true }); + await store.claimDailyMarketReward({ memberId: "member-1", dateKey: "2026-08-01", amount: 100 }); + await store.executeMarketTrade({ + memberId: "member-1", + symbol: "QLR", + side: "buy", + quantity: 2, + price: 126, + requestKey: "order-safe-1", + }); + assert.deepEqual(calls, [ + { + name: "classbot_claim_daily_market_reward", + args: { p_class_id: "class-private", p_member_id: "member-1", p_reward_date: "2026-08-01", p_amount: 100 }, + }, + { + name: "classbot_execute_market_trade", + args: { + p_class_id: "class-private", + p_member_id: "member-1", + p_symbol: "QLR", + p_side: "buy", + p_quantity: 2, + p_price: 126, + p_request_key: "order-safe-1", + }, + }, + ]); +}); diff --git a/apps/classbot/src/api/client.js b/apps/classbot/src/api/client.js index 2112a2b..195f460 100644 --- a/apps/classbot/src/api/client.js +++ b/apps/classbot/src/api/client.js @@ -198,6 +198,15 @@ export const api = { return request(`/api/portal/overview?${query.toString()}`); }, portalFiles() { return request("/api/portal/files"); }, + portalMarket() { return request("/api/portal/market"); }, + portalClaimMarketReward() { return request("/api/portal/market/reward", { method: "POST" }); }, + portalMarketOrder(input, options = {}) { + return request("/api/portal/market/orders", { + method: "POST", + headers: idempotencyHeaders("market-order", input, options), + body: input, + }); + }, portalFileUrl(value) { if (!value) return ""; const url = new URL(value, location.href); diff --git a/apps/classbot/src/components/AppShell.jsx b/apps/classbot/src/components/AppShell.jsx index 1c697ab..a74ca0e 100644 --- a/apps/classbot/src/components/AppShell.jsx +++ b/apps/classbot/src/components/AppShell.jsx @@ -1,9 +1,9 @@ import { CalendarDays, ChevronDown, Clock3, - FolderOpen, LayoutDashboard, Megaphone, Plus, Settings, Users, X, + FolderOpen, LayoutDashboard, Megaphone, Menu, Plus, Settings, TrendingUp, Users, X, } from "lucide-react"; -const navItems = [ +const adminNavItems = [ ["today", "오늘", LayoutDashboard], ["events", "일정", CalendarDays], ["notices", "공지", Megaphone], @@ -11,6 +11,16 @@ const navItems = [ ["members", "구성원", Users], ["settings", "설정", Settings], ]; +const portalSidebarItems = [ + ...adminNavItems.slice(0, 4), + ["market", "가상 주식", TrendingUp], + ...adminNavItems.slice(4), +]; +const portalBottomItems = [ + ...adminNavItems.slice(0, 4), + ["market", "가상주식", TrendingUp], + ["more", "더보기", Menu], +]; export function Brand() { return
Quilo schedule
; @@ -24,7 +34,9 @@ export function Sidebar({ profileName = "구민준", profileRole = "관리자", menuLabel = "Quilo schedule 메뉴", + portal = false, }) { + const navItems = portal ? portalSidebarItems : adminNavItems; return (