프로젝트

일반

사용자정보

Actions

이슈 #420

진행중
JL JL

Redeem Code 시스템 — 사용권 발급/리딤/관리 (QR 코드 기반)

이슈 #420: Redeem Code 시스템 — 사용권 발급/리딤/관리 (QR 코드 기반)

James Lee이(가) 약 6시간 전에 추가함. 약 6시간 전에 수정됨.

상태:
해결
우선순위:
높음
담당자:
목표버전:
시작일:
2026/04/28
완료일:
2026/04/29 (약 6달 지연)
진척도:

50%

추정시간:
(합계: 0:00 시간)

설명

개요

Edge Server 에서 AI Service 사용권 및 AGX 포인트 사용권을 Redeem Code (QR Code) 형태로 발급하고, Alphea App 사용자가 코드를 입력해 리딤할 수 있는 시스템을 구현한다.

사용권 종류

종류 효과 비고
AGX_POINTS AGX 포인트 일시 충전 예: 10,000 / 100,000 AGX
AI_SERVICE_MONTHLY AI Service 월간 사용권 일수로 환산 (30일 단위)
AI_SERVICE_YEARLY AI Service 연간 사용권 일수로 환산 (365일 단위)
CUSTOM 자유 조합 AGX + AI 사용권 동시 부여

코드 발급 정책

  • 포맷: ALPHEA-XXXX-XXXX-XXXX (Crockford Base32, 0/O/1/I 제외)
  • 단일 발급 + 배치 발급 (1회 최대 1,000개)
  • 만료일 설정 가능 (NULL = 무기한)
  • 재사용 횟수 설정 가능 (기본 1회용)
  • revoke (발급 취소) 기능
  • 발급 시 메모/사유, 캠페인 batch_id 기록

리딤 정책

  • 리딤은 인증된 사용자만 가능 (GoTrue JWT 필수)
  • 동일 사용자의 같은 코드 중복 리딤 금지 (UNIQUE (code, user_id))
  • AGX 적립은 wallet_adjustments ledger 테이블에 기록 (감사/롤백 가능)
  • AI Service 사용권은 ai_service_entitlements 의 expires_at 을 누적 연장

DB 스키마 (migrations/0008_redeem_codes.sql)

CREATE TABLE redeem_codes (
  code              TEXT PRIMARY KEY,
  batch_id          UUID,
  reward_type       SMALLINT NOT NULL,
  agx_amount        DOUBLE PRECISION DEFAULT 0,
  ai_service_days   INT DEFAULT 0,
  note              TEXT,
  max_redemptions   INT NOT NULL DEFAULT 1,
  redemption_count  INT NOT NULL DEFAULT 0,
  expires_at        TIMESTAMPTZ,
  created_by        UUID,
  created_at        TIMESTAMPTZ DEFAULT NOW(),
  revoked_at        TIMESTAMPTZ
);

CREATE TABLE redeem_history (
  id              BIGSERIAL PRIMARY KEY,
  code            TEXT NOT NULL REFERENCES redeem_codes(code),
  user_id         UUID NOT NULL,
  redeemed_at     TIMESTAMPTZ DEFAULT NOW(),
  agx_credited    DOUBLE PRECISION DEFAULT 0,
  ai_days_added   INT DEFAULT 0,
  UNIQUE (code, user_id)
);

CREATE TABLE ai_service_entitlements (
  user_id      UUID PRIMARY KEY,
  expires_at   TIMESTAMPTZ NOT NULL,
  updated_at   TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE wallet_adjustments (
  id             BIGSERIAL PRIMARY KEY,
  user_id        UUID NOT NULL,
  delta_agx      DOUBLE PRECISION NOT NULL,
  reason         TEXT NOT NULL,            -- 'redeem' / 'admin_credit' / 'admin_debit' 등
  ref_code       TEXT,                     -- redeem_codes.code 등 외부 참조
  created_by     UUID,
  created_at     TIMESTAMPTZ DEFAULT NOW()
);

Proto (proto/alphea/v1/redeem.proto)

service RedeemService {
  rpc RedeemCode(RedeemCodeRequest) returns (RedeemCodeResponse);
  rpc ListMyRedemptions(ListMyRedemptionsRequest) returns (ListMyRedemptionsResponse);
}

발급/관리는 admin REST API 만 제공 (gRPC 노출 X).

Admin REST API (src/admin/mod.rs)

POST   /api/redeem-codes              # 단일/배치 발급 (최대 1000)
GET    /api/redeem-codes              # 목록 (필터: type/status/batch/expires)
GET    /api/redeem-codes/{code}       # 상세 + 리딤 이력
POST   /api/redeem-codes/{code}/revoke

Dev Console UI

새 탭 "Redeem Codes" 추가:

  • 발급 폼 (단일/배치, 타입/금액/만료일/메모/배치 사이즈)
  • 목록 테이블 (필터/검색/페이지네이션)
  • 상세 패널 (리딤 이력 표시, QR 코드 실시간 렌더 — qrcode.min.js)
  • CSV 다운로드 (배치 발급 직후 / 목록 export)
  • 코드 취소 버튼

구현 단계

  • Phase 0 — AGX 잔액을 wallet-scope → account-scope 로 마이그레이션 (별도 이슈로 분리)
  • Phase 1 — DB migration + store/redeem.rs, store/wallet_adjustments.rs
  • Phase 2 — Proto + Handler (RedeemService)
  • Phase 3 — Admin REST API
  • Phase 4 — Dev Console UI

의존성

  • Phase 0 (AGX account-scope 전환) 가 선행되어야 한다 — 현재 AGX 가 wallet 종속이라 user 단위 적립 / 차감이 불가능.
  • GoTrue 인증(#411) 기반 — 리딤은 GoTrue JWT 의 user_id 로 식별.

하위 일감 2 (한 건 진행 중 — 한 건 완료)

이슈 #425: QR 코드 카드 디자인완료Jade Kim2026/04/282026/04/29

Actions
이슈 #426: 리딤 코드 SNS/문자 발송 기능신규James Lee2026/04/30

Actions

연결된 일감 3 (3 건 진행 중 — 모두 미완료)

다음 일감과 관련됨: 이슈 #411: Supabase GoTrue 인증 전환 — wallet-based auth 제거 및 GoTrue JWT 통합해결James Lee2026/04/24

Actions
다음 일감과 관련됨: 업무 #423: Docker 빌드 실패 — time crate 0.3.47 가 rustc 1.88+ 요구, builder image 가 1.85신규James Lee2026/04/27

Actions
다음 일감을 우선 진행: 이슈 #421: AGX 잔액을 Wallet 종속 → Account(GoTrue user_id) 종속으로 전환해결James Lee2026/04/27

Actions

JL James Lee이(가) 6달 전에 변경 Actions #1

  • 완료일을(를) 2026/04/28(으)로 지정되었습니다.
  • 시작일을(를) 2026/04/27에서 2026/04/28(으)로 변경되었습니다.
  • 다음 일감을 우선 진행:에 이슈 #421: AGX 잔액을 Wallet 종속 → Account(GoTrue user_id) 종속으로 전환이(가) 추가되었습니다.

JL James Lee이(가) 6달 전에 변경 Actions #2

  • 다음 일감과 관련됨:에 이슈 #411: Supabase GoTrue 인증 전환 — wallet-based auth 제거 및 GoTrue JWT 통합이(가) 추가되었습니다.

JL James Lee이(가) 6달 전에 변경 Actions #3

Phase 1 진행 — DB Migration + Store Layer

commit:server|a2df7c8

선행 이슈 #421 (AGX 계정 스코프 전환) 완료 후 Redeem Code 시스템 본격 구현 시작.

DB (migrations/0008_redeem_codes.sql)

3개 테이블 신설:

  • redeem_codes — 발급된 코드 메타. code TEXT PK, batch_id, reward_type, agx_amount, ai_service_days, max_redemptions, redemption_count, expires_at, created_by, revoked_at. CHECK 제약으로 음수 방지.
  • redeem_history — 리딤 이력. UNIQUE(code, user_id) 가 동일 사용자의 중복 리딤 차단 (max_redemptions > 1 인 경우에도 사용자별 1회).
  • ai_service_entitlements — AI Service 사용권 잔여. user_id PK, expires_at. 단일 만료일 누적 연장 방식.

Store (src/store/redeem.rs) — 주요 API

// 발급
generate_code() -> String                    // ALPHEA-XXXX-XXXX-XXXX (Crockford Base32, no I/L/O/U)
issue_batch(count, reward_type, agx, ai_days,
            note, max_redemptions, expires_at, created_by) -> Vec<CodeRow>

// 조회 / 취소
get_code(code) -> Option<CodeRow>
list_codes(filter, limit, offset)            // filter: type/batch_id/active_only
count_codes(filter)
revoke(code) -> bool

// 리딤 (단일 트랜잭션, FOR UPDATE 잠금)
redeem(code, user_id) -> Result<RedeemResult, RedeemError>

// 리딤 이력
list_history_for_code(code)
list_history_for_user(user_id, limit, offset)
count_history_for_user(user_id)

// AI 사용권 조회
get_ai_entitlement(user_id) -> Option<AiEntitlement>   // is_active() 헬퍼

리딤 트랜잭션 보장

redeem() 한 번 호출이 다음을 단일 SQL 트랜잭션으로 처리하여 부분 적립이 발생하지 않음:

  1. redeem_codes row FOR UPDATE 잠금 (동시 리딤 race 방지)
  2. revoked / expired / exhausted 검사 → 각각 RedeemError variant
  3. redeem_history insert — UNIQUE(code, user_id) 위반 시 AlreadyRedeemed
  4. redemption_count 증가
  5. agx_amount > 0 → wallet_adjustments insert (reason='redeem', ref_code=<code>)
  6. ai_service_days > 0 → ai_service_entitlements upsert. 만료일 = GREATEST(현재 만료일, NOW()) + days

코드 발급

  • Crockford Base32 알파벳 (0-9 A-Z 중 I/L/O/U 제외) — 32^12 ≈ 1.15 × 10¹⁸ 키스페이스
  • count == 1 이면 batch_id NULL, 그 외엔 fresh UUID
  • 충돌 (PK violation) 시 자동 재시도 최대 3회

Errors

RedeemError enum (thiserror):

  • NotFound / Revoked / Expired / Exhausted / AlreadyRedeemed / Db(sqlx::Error)

→ Phase 2 의 handler 가 각 variant 를 적절한 gRPC Status (NotFound, FailedPrecondition, AlreadyExists 등)로 매핑.

검증

  • cargo build 성공
  • cargo test --lib 35 tests passed (3 신규: code 포맷 / unique / RewardType roundtrip)
  • cargo clippy --lib --tests --bins clean

다음 (Phase 2)

Proto RedeemService 정의 + handler 구현 — RedeemCode (caller 가 자기 user_id 로 리딤) + ListMyRedemptions.

JL James Lee이(가) 6달 전에 변경 Actions #4

Phase 2 진행 — RedeemService gRPC

commit:server|cd8b8cc

Mobile app 이 사용자 자격으로 호출하는 RPC 두 개를 expose. 발급 / 관리는 Phase 3 의 admin REST API 만 담당하므로 본 서비스에는 admin RPC 없음.

Proto (proto/alphea/v1/redeem.proto)

service RedeemService {
  rpc RedeemCode(RedeemCodeRequest) returns (RedeemCodeResponse);
  rpc ListMyRedemptions(ListMyRedemptionsRequest) returns (ListMyRedemptionsResponse);
}

enum RewardType {
  REWARD_TYPE_UNSPECIFIED      = 0;
  REWARD_TYPE_AGX_POINTS       = 1;
  REWARD_TYPE_AI_SERVICE_MONTH = 2;
  REWARD_TYPE_AI_SERVICE_YEAR  = 3;
  REWARD_TYPE_CUSTOM           = 4;
}

RedeemCodeResponse 는 적립 결과(AGX/AI days)와 함께 적립 후 잔액(new_balance_agx) + AI 사용권 만료일(ai_expires_at) 을 포함 — mobile UI 가 한 번의 호출로 즉시 화면 갱신 가능.

Handler (src/handler/redeem.rs)

  • 두 RPC 모두 require_user_token 으로 보호 — node 토큰 거부.
  • public_path_set 에 추가하지 않아 default-deny 적용.
  • 입력 정규화 normalize_code(): 공백 / _ / . 제거, 대문자 변환. 하이픈은 canonical ALPHEA-XXXX-XXXX-XXXX 포맷 유지를 위해 보존.

RedeemError → gRPC Status 매핑:

사유 gRPC Code
NotFound NOT_FOUND
Revoked FAILED_PRECONDITION
Expired FAILED_PRECONDITION
Exhausted FAILED_PRECONDITION
AlreadyRedeemed ALREADY_EXISTS
Db(_) INTERNAL

Wiring

  • build.rs — redeem.proto 컴파일 대상 추가
  • src/handler/mod.rs — RedeemHandler re-export
  • src/server/mod.rs — RedeemServiceServer::new(...) add_service

검증

  • cargo build 성공
  • cargo test --lib 37 passed (2 신규 normalize 테스트)
  • cargo clippy --lib --tests --bins clean

다음 (Phase 3)

관리자용 REST API:

  • POST /api/redeem-codes — 단일/배치 발급 (최대 1000)
  • GET /api/redeem-codes — 목록 (필터)
  • GET /api/redeem-codes/{code} — 상세 + 리딤 이력
  • POST /api/redeem-codes/{code}/revoke

JL James Lee이(가) 6달 전에 변경 Actions #5

Phase 3 진행 — Admin REST API

commit:server|bcdb4bf

Dev Console (Phase 4) 가 사용할 백엔드 4개 엔드포인트 추가. 모두 admin JWT 보호.

엔드포인트

Method Path 설명
POST /api/redeem-codes 단일 / 배치 발급 (max 1000)
GET /api/redeem-codes 목록 — 필터: reward_type, batch_id, status=active|inactive
GET /api/redeem-codes/{code} 상세 + 리딤 이력 (history[])
POST /api/redeem-codes/{code}/revoke 발급 취소

POST /api/redeem-codes body

{
  "count": 100,
  "reward_type": 1,
  "agx_amount": 10000,
  "ai_service_days": 0,
  "note": "프리세일 캠페인 1차",
  "max_redemptions": 1,
  "expires_at": "2026-12-31"
}

응답:

{
  "issued": 100,
  "batch_id": "uuid-v4-string",
  "codes": [{ "code": "ALPHEA-XXXX-XXXX-XXXX", "active": true, ... }, ...]
}

Validation

  • count 1..=1000 (MAX_BATCH_SIZE)
  • reward_type ∈ {1, 2, 3, 4} (store::redeem::RewardType)
  • agx_amount, ai_service_days ≥ 0
  • 둘 다 0 이면 reject (no-op 코드 방지)
  • max_redemptions ≥ 1 (단, UNIQUE(code, user_id) 제약으로 사용자별 1회는 항상 보장)
  • expires_at: RFC3339 또는 YYYY-MM-DD 또는 빈 문자열(무기한)

GET /api/redeem-codes 응답 row

active 플래그를 서버에서 미리 계산하여 UI 에서 별도 로직 불필요:

active = !revoked && !expired && (redemption_count < max_redemptions)

Helper

  • redeem_code_to_json — active 플래그 + 모든 컬럼
  • redeem_history_to_json — 리딤 이력
  • parse_optional_timestamp — RFC3339 / YYYY-MM-DD / 빈문자열 통합

Auth

모든 엔드포인트 require_auth (기존 admin JWT 패턴). 발급/취소 권한 분리는 추후 admin role 도입 시 별도 이슈로 처리.

검증

  • cargo build 성공
  • cargo test --lib 37 passed
  • cargo clippy --lib --tests --bins clean

다음 (Phase 4)

Dev Console 에 "Redeem Codes" 탭 추가 — 발급 폼 / 목록 / 상세 / QR 코드 렌더링 / CSV 다운로드 / 취소 버튼.

JL James Lee이(가) 6달 전에 변경 Actions #6

  • 상태을(를) 신규에서 해결(으)로 변경되었습니다.

Phase 4 완료 — Dev Console UI + Redeem Code 시스템 전체 완료

commit:server|2638d53

마지막 단계. 발급 / 목록 / 상세 / 취소 / QR 시각화 / CSV 다운로드를 한 탭에서 처리.

UI 카드 (Wallet ↔ Logs 사이에 Redeem 탭 추가)

1. Issue Codes

  • count (1–1000) / reward_type (select) / agx_amount / ai_service_days / max_redemptions / expires_at / note
  • 단일 발급 (count==1) 시 발급 직후 detail input 자동 채움 + QR 자동 표시
  • 배치 발급 시 Export Last Batch CSV 버튼 활성화

2. List Codes

  • 필터: reward_type / status (active|inactive) / batch_id / limit
  • 결과는 인라인 테이블 — active/inactive 뱃지, type 색상 pill, redemption_count/max, 만료일
  • 코드 클릭 → detail input 자동 채움 + QR 표시

3. Code Detail / QR

  • code 입력 + Lookup / Show QR / Revoke (confirmation 다이얼로그)
  • QR canvas (180×180, error correction level M)

QR 렌더링

  • qrcode@1.5.3 (jsdelivr CDN — cdn.jsdelivr.net)
  • 라이브러리 로드 실패 시 "QR library load failed" 안내. 추후 self-host 필요할 수 있음.

CSV Export

  • RFC 4180 escape (, / " / 줄바꿈 처리)
  • 파일명: redeem-codes-{batch_id|single}-{ISO timestamp}.csv

검증

  • cargo build 성공
  • cargo test --lib 37 passed
  • cargo clippy --lib --tests --bins clean

🎯 Redeem Code 시스템 전체 완료

Phase Commit 내용
0 657e7fa AGX 잔액을 wallet → user_id 종속으로 전환 (#421 — 선행 이슈)
1 a2df7c8 DB 마이그레이션 (0008_redeem_codes.sql) + store/redeem.rs
2 cd8b8cc Proto RedeemService + handler/redeem.rs (RedeemCode, ListMyRedemptions)
3 bcdb4bf Admin REST API — issue (max 1000) / list / detail / revoke
4 2638d53 Dev Console "Redeem" 탭 + QR 렌더링 + CSV export

완성된 기능

관리자 (Dev Console)

  • 단일 / 배치 발급 (1회 최대 1000개)
  • 4가지 reward_type — AGX 포인트 / AI Service 월간·연간 사용권 / CUSTOM
  • 만료일, 다중 사용 횟수, 발급 메모 설정
  • 코드 목록 (필터링), 상세 + 리딤 이력 조회
  • QR 코드 즉시 렌더 + CSV 일괄 다운로드 → 인쇄/공유 가능
  • 발급 취소(revoke)

모바일 앱 (gRPC)

  • RedeemService.RedeemCode — 코드 입력으로 AGX/AI 사용권 즉시 적립. 응답에 적립 후 잔액 + AI 만료일 포함
  • RedeemService.ListMyRedemptions — 내 리딤 이력
  • 입력 정규화 (대소문자/공백/_/. 무시)
  • 트랜잭션 보장 — 부분 적립 없음, 동시 리딤 race-safe (FOR UPDATE)

검증 / 보안

  • 동일 사용자의 같은 코드 중복 리딤 차단 (UNIQUE(code, user_id))
  • revoked / expired / exhausted 각각 별도 gRPC Status 매핑
  • AGX 잔액은 wallet_adjustments ledger 에 기록 (감사 가능, 향후 롤백 가능)
  • AI 사용권은 ai_service_entitlements 단일 만료일 누적 연장

이슈 마감 처리

본 이슈와 선행 이슈 #421 모두 완료. 4개 phase 모두 main 에 머지/푸시됨.

JL James Lee이(가) 6달 전에 변경 Actions #7

  • 다음 일감과 관련됨:에 업무 #423: Docker 빌드 실패 — time crate 0.3.47 가 rustc 1.88+ 요구, builder image 가 1.85이(가) 추가되었습니다.

JL James Lee이(가) 6달 전에 변경 Actions #8

정책 변경 — Reward type 을 USD 고정 3종으로 단순화

commit:server|1ce5955

변경 배경

초기 설계는 임의 AGX 수량 + AI 사용권 일수를 발급하는 4종 reward_type 이었으나, 실제 운영 정책이 $200 / $1000 / $2000 USD 가치 3종 고정으로 결정. AGX 환산은 본 시스템 밖에서 처리 (option B).

변경 요약

DB (migrations/0009_redeem_codes_usd_only.sql)

  • DROP TABLE ai_service_entitlements (AI Service 기능 폐기)
  • redeem_codes / redeem_history 클린 리라이트 (#420 Phase 1 패턴)
  • 컬럼 변경:
    • agx_amount, ai_service_days → usd_amount (200/1000/2000)
    • agx_credited, ai_days_added → usd_credited
    • reward_type CHECK (1, 2, 3) 추가
  • 기존 redeem_* 데이터 폐기

Proto (proto/alphea/v1/redeem.proto)

enum RewardType {
  REWARD_TYPE_UNSPECIFIED = 0;
  REWARD_TYPE_USD_200     = 1;
  REWARD_TYPE_USD_1000    = 2;
  REWARD_TYPE_USD_2000    = 3;
}

message RedeemCodeResponse {
  RewardType reward_type  = 1;
  int32      usd_credited = 2;
}

이전의 agx_credited / ai_days_added / ai_expires_at / new_balance_agx 응답 필드 모두 제거.

Store (src/store/redeem.rs)

  • RewardType::Usd200/Usd1000/Usd2000 + usd_amount() 매핑
  • issue_batch() 시그니처 단순화 — agx_amount, ai_service_days 인자 제거
  • redeem() 에서 wallet_adjustments insert + ai_service_entitlements upsert 모두 제거. history 기록 + count++ 만 수행.
  • AiEntitlement struct, get_ai_entitlement() 함수 삭제

Handler (src/handler/redeem.rs)

  • balance 조회 제거 (적립 없음 → 잔액 변화 없음)
  • reward_type → proto enum 매핑 단순화

Admin REST (src/admin/mod.rs)

  • IssueCodesReq: agx_amount, ai_service_days 필드 제거
  • validation 단순화: reward_type ∈ {1,2,3} 만 검사

Dev Console UI (src/admin/mod.rs)

  • Issue 폼: AGX/AI days 입력 카드/필드 제거
  • reward_type select: 1 — USD $200, 2 — USD $1,000, 3 — USD $2,000
  • List 필터 select: $200 / $1000 / $2000
  • JS: RT_LABEL = {1:'$200',2:'$1000',3:'$2000'}, CSV 헤더 변경

검증

  • cargo build 성공
  • cargo test --lib 38 passed (신규 reward_type_usd_amount_mapping 포함)
  • cargo clippy --all-targets clean

영향

  • Mobile app: RedeemService.RedeemCode 응답 형식이 바뀌었으므로 클라이언트 파싱 코드 업데이트 필요 (agx_credited / ai_* 필드 → usd_credited).
  • DB: 기존 redeem 데이터는 마이그레이션 0009 적용 시 모두 폐기 (dev 환경 가정).
  • wallet_adjustments: 본 변경으로 redeem 경로에서 사용 안 함. 표는 유지 (admin manual credit 등 향후 사용 가능성).

JL James Lee이(가) 6달 전에 변경 Actions #9

후속 — List 뷰에 QR 썸네일 표시

commit:server|c731501

변경

Dev Console 의 List Codes 결과에 각 코드별 QR 썸네일을 inline 으로 표시 (80×80, 흰 배경, 클릭 가능).

CSS:

  • .rc-row-qr — 80×80 흰 배경 canvas, flex-shrink:0
  • .rc-row-meta — 메타 영역 2줄 레이아웃 (pill+code+상태 / 사용횟수+만료+note)

JS (rcRenderList):

  • 각 row 에 <canvas data-code="..."> 추가
  • DOM 삽입 후 codes.forEach 로 QRCode.toCanvas 호출 (width:72, margin:1, EC level M)
  • canvas / 코드 텍스트 둘 다 클릭 시 rcPickCode → Detail 패널 큰 QR 로 이동
  • note 가 있으면 line2 에 📝 아이콘 + escH escape 해서 표시

UX 효과

  • 1 페이지 (default 50개) 의 모든 QR 을 한 번에 시각적으로 확인 가능
  • 관리자가 발급 직후 화면 캡처 / 인쇄로 즉시 배포용 자료 만들 수 있음
  • 50 캔버스 동시 렌더 — 브라우저 perf 영향 무시 수준

검증

  • cargo build 성공
  • cargo test --lib 38 passed
  • cargo clippy --all-targets clean

JL James Lee이(가) 6달 전에 변경 Actions #10

후속 — Server-side QR PNG 생성 + DB 저장 + img 서빙

commit:server|3a456f2

배경

브라우저 사이드 QR 라이브러리 (jsdelivr CDN 의 qrcode.min.js) 가 외부 망 차단 / CSP / 어떤 환경 이슈로 로드 실패하는 경우 "QR library load failed" 메시지만 떠서 사용 불가. 또한 사용자가 발급된 QR 을 외부 시스템 (인쇄 자동화 등) 에서 사용할 수 있어야 한다는 요구도 있어 server-side 로 전환.

변경

DB (migrations/0010_redeem_codes_qr_png.sql) — additive

ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS qr_png BYTEA;

NULLABLE — 기존 row 폐기 없이 적용 가능. 신규 INSERT 는 항상 채움.

Cargo.toml

  • qrcode = "0.14" (default-features=false, features=["image"])
  • image = "0.25" (default-features=false, features=["png"])

PNG 인코딩만 활성화하여 다른 포맷 dep 들이 들어오지 않도록 조정.

Store (src/store/redeem.rs)

  • generate_qr_png(code) — 256×256 흑백 PNG 생성 (image::Luma<u8>)
  • insert_code() — 발급 시 PNG 도 같은 INSERT 에 포함
  • get_code_qr_png(code) — DB 의 PNG 바이트 조회

Admin REST

GET /api/redeem-codes/{code}/qr.png
  • Content-Type: image/png + Cache-Control: public, max-age=86400, immutable
  • 인증 없음 — 코드 자체가 secret (32^12 ≈ 1.15 × 10^18 keyspace, brute-force 불가). <img src> 가 자동 헤더 부여 못해 admin auth 시 JS hackery 필요 → public 유지가 보안 이득 < UX 비용.
  • DB qr_png 가 NULL 인 row (마이그레이션 이전 데이터) → on-the-fly 생성하여 graceful fallback

UI

  • <script src="...cdn...qrcode..."> 태그 제거 (외부 의존성 zero)
  • Detail panel: <canvas> → <img src="/api/.../qr.png">
  • List row: <canvas data-code> → <img src="/api/.../qr.png">
  • CSS: .qr-stage img + image-rendering:pixelated (픽셀 선명도)
  • JS: rcRenderList / rcShowQr 의 QRCode.toCanvas 호출 전부 제거 → img.src 만 설정. "QR library load failed" fallback 도 더 이상 발생 불가.

효과 비교

이전 지금
외부 의존 jsdelivr CDN 없음
네트워크 차단 시 표시 실패 정상 동작
QR 일관성 브라우저별 차이 가능 단일 서버 생성 = 보장
DB 저장 ❌ ✅ qr_png BYTEA
외부 시스템 사용 불가 GET /api/.../qr.png 직접

검증

  • cargo build 성공
  • cargo test --lib 39 passed (신규 generate_qr_png_returns_valid_png_bytes)
  • cargo clippy --all-targets clean

서버 적용

git pull && docker compose up -d --build app

마이그레이션 0010 은 ADD COLUMN IF NOT EXISTS 라 데이터 보존하면서 적용. 기존 코드의 QR 은 첫 호출 시 자동 생성됨.

JL James Lee이(가) 6달 전에 변경 Actions #11

후속 — Phase A: phone + MMS send (stub provider)

commit:server|b021104

구현 범위 (Phase A — provider-agnostic 기반)

DB (migrations/0011_redeem_codes_phone.sql) — additive

  • redeem_codes.phone TEXT (수령자 번호, optional)
  • redeem_codes.last_sms_sent_at TIMESTAMPTZ (마지막 발송 시각)

Domain (src/domain/sms.rs)

  • SmsClient trait + OutgoingMms / SmsSendOutcome / SmsError
  • StubSmsClient — 메시지 본문 + 첨부 사이즈를 info! 로 로깅, 실제 발송 없음
  • normalize_phone — 숫자 / + 만 남기는 단순 정규화 (provider 별 strict 검증은 각 구현체에서)
  • 4 신규 unit test

AppState.sms: Arc<dyn SmsClient> — StubSmsClient::arc() 주입

Store (src/store/redeem.rs)

  • CodeRow.phone, CodeRow.last_sms_sent_at 필드 추가
  • insert_code / issue_batch 가 phone 받음
  • update_meta(code, note, phone) — 빈 문자열은 NULL 로 저장 (UI 의 "지우기" 의도)
  • stamp_sms_sent(code) — last_sms_sent_at = NOW()

Admin REST

Method Path 설명
POST /api/redeem-codes phone optional 필드 추가
PATCH /api/redeem-codes/{code} note / phone 편집. null/누락=변경X, 빈문자열=NULL
POST /api/redeem-codes/{code}/send-sms 등록된 phone 으로 MMS 발송

MMS body (한국어)

[Alphea] $1000 사용권이 발급되었습니다.

코드: ALPHEA-XXXX-XXXX-XXXX
유효기간: 2026-12-31

첨부된 QR 코드를 스캔하거나 코드를 직접 입력해 사용하세요.

(이미지: 256×256 PNG QR 첨부)

Dev Console UI

  • Issue 폼: phone optional 입력
  • Detail 카드: note/phone 편집기 (input + Save) + Send SMS 버튼 + 마지막 발송 시각 표시
  • rcPickCode: 자동으로 GET → 편집 필드 채움

Stub 동작 예시 (서버 로그)

INFO admin: [stub-sms] would send MMS (no real delivery)
  to=01012345678 body_len=98 image_bytes=312 body=[Alphea] $1000 ...

검증

  • cargo build ✅
  • cargo test --lib 42 passed (신규 4: normalize_phone / stub_client_logs_and_returns_ok / stub_client_rejects_empty_phone + 기존)
  • cargo clippy --all-targets clean

Phase B (real provider) 전환 시 변경 범위

  1. src/domain/sms.rs 에 새 struct (AligoSmsClient 등) + SmsClient impl
  2. Config 에 API key env 추가
  3. main.rs / server/mod.rs 의 StubSmsClient::arc() 만 교체
  4. UI / Store / 마이그레이션 / 라우트 모두 그대로

본 abstraction 덕에 provider 교체는 한 줄 변경에 가깝다.

JK Jade Kim이(가) 6달 전에 변경 Actions #12

  • Subtask에 #425이(가) 추가되었습니다.

CP Chris Park이(가) 6달 전에 변경 Actions #13

  • Subtask에 #426이(가) 추가되었습니다.

JL James Lee이(가) 6달 전에 변경 Actions #14

후속 — SMS 채널 (#426) 의 SNS 부분 구현

commit:server|f6217a8

본 commit 은 본 이슈의 자식 이슈인 #426 (리딤 코드 SNS/문자 발송 기능) 의 SMS 채널 (AWS SNS) 부분을 처리. 상세 변경 내역 / 잔여 범위는 #426 에 정리.

요약:

  • AwsSnsSmsClient 신규 — AWS SNS Publish API 호출
  • 한국 번호 → E.164 자동 변환
  • SNS 가 MMS 미지원이라 send_sms 본문에 QR URL 항상 포함 (PUBLIC_ADMIN_URL 설정 필요)
  • env 기반 provider 선택 (SMS_PROVIDER=stub|sns)

#426 의 잔여 범위 (Kakao / SES / 감사 테이블 / 배치 / 재발송 등) 는 별도 후속 작업.

JL James Lee이(가) 6달 전에 변경 Actions #15

후속 — Email 채널 도입 (#426 의 SES 부분)

commit:server|49543d4

본 이슈의 자식 #426 (리딤 코드 SNS/문자 발송) 의 email 채널 추가. Email 이 redeem 코드의 기본 발송 채널 이 되었고, SMS 는 optional secondary.

요약:

  • AwsSesEmailClient — AWS SES v2 SendEmail Raw MIME, HTML 본문 + inline QR PNG + plain text fallback
  • redeem_codes.email / last_email_sent_at 컬럼 추가 (additive)
  • Admin REST: IssueCodesReq.email, UpdateCodeReq.email, POST /send-email
  • UI: Issue 폼 email 필드 상단, Detail 에 Send Email 버튼

상세 / 잔여 범위는 #426 참고.

JL James Lee이(가) 6달 전에 변경 Actions #16

후속 — 카드 템플릿 + QR 합성

commit:server|376facf

발급된 redeem 코드의 QR 이미지를 단순 흑백 PNG 가 아니라 reward_type 별 브랜딩 카드 안에 합성하여 저장.

매핑

reward_type 카드 부제
Usd200 qr-card-starter.png AI App passes for 3 months
Usd1000 qr-card-pro.png AI App passes for 12 months
Usd2000 qr-card-founder.png AI App passes for 24 months

3 템플릿 모두 resources/ 에 보관 + include_bytes! 로 binary embed → 런타임 파일 IO 없음, Docker 마운트 불필요.

합성 로직 (store/redeem.rs)

  • generate_qr_card(code, RewardType) — 메인 API
  • detect_white_square() — 템플릿 dimension / 좌표를 hard-code 하지 않고 흰 정사각형 위치 자동 감지 (가장 긴 흰 픽셀 가로 run → bounding box 확장). 추후 템플릿 교체 / 새 reward type 추가 시 코드 수정 불필요.
  • QR 은 흰 영역 중앙에 8% inset (quiet zone 효과)
  • Resize 는 FilterType::Nearest 로 모듈 선명도 유지

호출처 변경

  • insert_code — 발급 시 자동으로 카드 PNG 저장
  • Admin 의 3 fallback 경로 (send-sms / send-email / qr.png endpoint) — generate_qr_card_for_type(code, row.reward_type) 으로 교체
  • 기존 generate_qr_png 제거

미리보기

cargo run --example qr_card_preview 실행 시 out/ 에 3 reward type 별 PNG 가 떨어져 시각 검증 가능. 시각 확인 결과 — QR 모듈 선명, 흰 정사각형 정중앙 배치, Alphea 브랜딩 유지.

수신자 경험 변화

이메일 / SMS 링크 / Dev Console 어디서든 동일한 카드 PNG 가 표시됨:

  • Email: HTML 본문 inline 이미지 (cid:qr@alphea) 가 카드 전체
  • SMS: 본문 URL 탭하면 카드 표시
  • Dev Console list/detail: 카드 썸네일 + 큰 카드 모두

검증

  • cargo build ✅
  • cargo test --lib 54 passed (신규: generate_qr_card_returns_valid_png_for_each_reward_type, generate_qr_card_for_type_rejects_invalid)
  • cargo clippy --all-targets clean

JL James Lee이(가) 6달 전에 변경 Actions #17

후속 — Audit 로그 (WARN level) + caller 추적

commit:server|d76f105

운영상 중요한 redeem state-change 작업에 audit 로그 추가. 실행자 (caller user_id) 와 컨텍스트 모두 기록되어 Dev Console Log 조회에서 즉시 확인 가능.

로그 항목

Action Level Fields
issue WARN caller, issued, batch_id, reward_type, usd_amount, has_phone, has_email, max_redemptions, expires_at
update WARN caller, code, note_changed, phone_changed, email_changed (값 미기록 — PII 보호)
revoke WARN caller, code
send-sms WARN caller, code, to (E.164), provider, provider_message_id
send-email WARN caller, code, to (email), provider, provider_message_id

Dev Console 에서 확인하는 법

  1. Logs 탭 으로 이동
  2. Filter level 드롭다운 → WARN 선택 (또는 ALL 유지하면 INFO 와 섞여 보임)
  3. Target filter 입력에 audit 또는 redeem 입력 → audit::redeem 만 필터링
  4. WARN 색상 (노란색) 으로 강조되어 한눈에 파악

Tracing target

모든 audit 로그는 target: "audit::redeem" 으로 명시 — 일반 핸들러 디버그 로그와 분리되어 추적/필터 용이.

PII 정책

  • send-sms / send-email 의 수신자는 audit 상 핵심이라 그대로 기록 (admin 내부 log)
  • update 의 phone/email 새 값은 기록하지 않고 변경 여부 (*_changed: bool) 만 기록 — 단순 PATCH 가 노이즈 만드는 걸 막음

검증

  • cargo build 성공
  • cargo test --lib 54 passed
  • cargo clippy --all-targets clean

운영 적용

git pull
docker compose up -d --build app

이후 Dev Console 의 Logs 탭에서 issue/revoke/send-* 동작 시 audit 로그 확인.

JL James Lee이(가) 6달 전에 변경 Actions #18

후속 — 이메일 본문 템플릿화 (minijinja) + 다중 inline 이미지

commit:server|3d8c14e

이메일 HTML 을 코드에서 분리하여 resources/redeem-email-template.html 로 외부화. 운영자가 코드 빌드 없이 디자인 수정 가능 (Git 으로 PR / 또는 SES 운영 시 별도 templating 도 가능).

신규 / 수정

Resources

  • resources/redeem-email-template.html — 사용자가 디자인한 풀 HTML (다크모드, Outlook, mobile 대응 포함). src 는 cid:pass@alphea / cid:qr@alphea 로 inline 참조하도록 수정, alt 는 {{ pass_alt }} / {{ qr_alt }} minijinja 변수
  • resources/pass-{starter,pro,founder}.png — 패키지 정보 박스 이미지 3종. include_bytes! 로 binary embed

Cargo

  • minijinja = "2" — Jinja2 호환 템플릿 엔진 (작고 빠름, ~30KB)

src/domain/email/mod.rs

  • OutgoingEmail.inline_png: Option<...> 단일 → inline_images: Vec<InlineImage> 다중
  • InlineImage { content_id, filename, bytes } 신규

src/domain/email/ses.rs

  • MIME builder 가 multipart/related 안에 image/png 파트를 N 개 emit
  • 신규 test: 다중 inline image (Content-ID: <pass@alphea>, <qr@alphea> 모두 출현 검증)

src/store/redeem.rs

  • pass_image_for(rt) — pass-*.png 바이트
  • pass_label_for(rt) — "Starter Pass" / "Pro Pass" / "Founder Pass"
  • pass_alt_for(rt) / qr_alt_for(rt) — 이메일 본문 alt 텍스트

src/admin/mod.rs (send-email handler)

  • email_env() static OnceLock — 템플릿 시작 시 1회 컴파일, 재사용
  • 본문은 minijinja::context! { pass_alt, qr_alt } 로 렌더
  • inline_images 에 pass + QR 두 이미지 동시 첨부 (cid: pass@alphea, qr@alphea)
  • Subject: [ALPHEA] {pass_label} 사용권이 발급되었습니다

Email 흐름

client clicks Send Email →
  load redeem_codes row →
  load qr_png from DB (or generate) →
  pick pass-{starter,pro,founder}.png from binary →
  render redeem-email-template.html via minijinja with pass_alt + qr_alt →
  build MIME with text/plain + text/html + 2 image/png inline →
  AwsSesEmailClient.send_email() →
  SES delivers, recipient sees Korean HTML email with logo
  + pass info box (matching their reward) + QR card (their code)

시각 검증

cargo run --example email_template_preview → out/email-{starter,pro,founder}.html 떨어뜨려 브라우저로 미리보기 가능.

  • Pro 카드 렌더 결과 확인: alt="Pro Pass — ALPHEA AI App 사용권 12개월, AI Builder Credit, VIP Beta Access" 정상 치환

검증

  • cargo build 성공
  • cargo test --lib 55 passed (다중 inline 신규 test 1개)
  • cargo clippy --all-targets clean

향후 확장 가능성

  • 이메일 다국어 (en/ko 등) — {{ lang }} 분기 또는 별도 템플릿 파일
  • 발송 시점 변수 — {{ now }} / {{ user_name }} 등 추가
  • 다른 이메일 (welcome / reminder 등) — 같은 pattern 으로 별도 템플릿 파일 추가
Actions

내보내기 PDF Atom