이슈 #420
진행중Redeem Code 시스템 — 사용권 발급/리딤/관리 (QR 코드 기반)
설명
개요¶
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_adjustmentsledger 테이블에 기록 (감사/롤백 가능) - 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로 식별.
JL James Lee이(가) 6달 전에 변경
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 트랜잭션으로 처리하여 부분 적립이 발생하지 않음:
-
redeem_codesrowFOR UPDATE잠금 (동시 리딤 race 방지) - revoked / expired / exhausted 검사 → 각각
RedeemErrorvariant -
redeem_historyinsert —UNIQUE(code, user_id)위반 시AlreadyRedeemed -
redemption_count증가 -
agx_amount > 0→wallet_adjustmentsinsert (reason='redeem',ref_code=<code>) -
ai_service_days > 0→ai_service_entitlementsupsert. 만료일 =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 --lib35 tests passed (3 신규: code 포맷 / unique / RewardType roundtrip) -
cargo clippy --lib --tests --binsclean
다음 (Phase 2)¶
Proto RedeemService 정의 + handler 구현 — RedeemCode (caller 가 자기 user_id 로 리딤) + ListMyRedemptions.
JL James Lee이(가) 6달 전에 변경
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(): 공백 /_/.제거, 대문자 변환. 하이픈은 canonicalALPHEA-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—RedeemHandlerre-export -
src/server/mod.rs—RedeemServiceServer::new(...)add_service
검증¶
-
cargo build성공 -
cargo test --lib37 passed (2 신규 normalize 테스트) -
cargo clippy --lib --tests --binsclean
다음 (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달 전에 변경
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¶
-
count1..=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 --lib37 passed -
cargo clippy --lib --tests --binsclean
다음 (Phase 4)¶
Dev Console 에 "Redeem Codes" 탭 추가 — 발급 폼 / 목록 / 상세 / QR 코드 렌더링 / CSV 다운로드 / 취소 버튼.
JL James Lee이(가) 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 --lib37 passed -
cargo clippy --lib --tests --binsclean
🎯 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_adjustmentsledger 에 기록 (감사 가능, 향후 롤백 가능) - AI 사용권은
ai_service_entitlements단일 만료일 누적 연장
이슈 마감 처리¶
본 이슈와 선행 이슈 #421 모두 완료. 4개 phase 모두 main 에 머지/푸시됨.
JL James Lee이(가) 6달 전에 변경
정책 변경 — 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_typeCHECK (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_adjustmentsinsert +ai_service_entitlementsupsert 모두 제거. history 기록 + count++ 만 수행. -
AiEntitlementstruct,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 --lib38 passed (신규reward_type_usd_amount_mapping포함) -
cargo clippy --all-targetsclean
영향¶
-
Mobile app:
RedeemService.RedeemCode응답 형식이 바뀌었으므로 클라이언트 파싱 코드 업데이트 필요 (agx_credited/ai_*필드 →usd_credited). - DB: 기존 redeem 데이터는 마이그레이션 0009 적용 시 모두 폐기 (dev 환경 가정).
- wallet_adjustments: 본 변경으로 redeem 경로에서 사용 안 함. 표는 유지 (admin manual credit 등 향후 사용 가능성).
JL James Lee이(가) 6달 전에 변경
후속 — 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 에 📝 아이콘 +escHescape 해서 표시
UX 효과¶
- 1 페이지 (default 50개) 의 모든 QR 을 한 번에 시각적으로 확인 가능
- 관리자가 발급 직후 화면 캡처 / 인쇄로 즉시 배포용 자료 만들 수 있음
- 50 캔버스 동시 렌더 — 브라우저 perf 영향 무시 수준
검증¶
-
cargo build성공 -
cargo test --lib38 passed -
cargo clippy --all-targetsclean
JL James Lee이(가) 6달 전에 변경
후속 — 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 --lib39 passed (신규generate_qr_png_returns_valid_png_bytes) -
cargo clippy --all-targetsclean
서버 적용¶
git pull && docker compose up -d --build app
마이그레이션 0010 은 ADD COLUMN IF NOT EXISTS 라 데이터 보존하면서 적용. 기존 코드의 QR 은 첫 호출 시 자동 생성됨.
JL James Lee이(가) 6달 전에 변경
후속 — 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)
-
SmsClienttrait +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 --lib42 passed (신규 4: normalize_phone / stub_client_logs_and_returns_ok / stub_client_rejects_empty_phone + 기존) -
cargo clippy --all-targetsclean
Phase B (real provider) 전환 시 변경 범위¶
-
src/domain/sms.rs에 새 struct (AligoSmsClient등) +SmsClientimpl -
Config에 API key env 추가 -
main.rs/server/mod.rs의StubSmsClient::arc()만 교체 - UI / Store / 마이그레이션 / 라우트 모두 그대로
본 abstraction 덕에 provider 교체는 한 줄 변경에 가깝다.
CP Chris Park이(가) 6달 전에 변경
- Subtask에 #426이(가) 추가되었습니다.
JL James Lee이(가) 6달 전에 변경
후속 — 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달 전에 변경
후속 — 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달 전에 변경
후속 — 카드 템플릿 + 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 --lib54 passed (신규:generate_qr_card_returns_valid_png_for_each_reward_type,generate_qr_card_for_type_rejects_invalid) -
cargo clippy --all-targetsclean
JL James Lee이(가) 6달 전에 변경
후속 — 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 에서 확인하는 법¶
- Logs 탭 으로 이동
-
Filter level 드롭다운 →
WARN선택 (또는 ALL 유지하면 INFO 와 섞여 보임) -
Target filter 입력에
audit또는redeem입력 → audit::redeem 만 필터링 - 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 --lib54 passed -
cargo clippy --all-targetsclean
운영 적용¶
git pull
docker compose up -d --build app
이후 Dev Console 의 Logs 탭에서 issue/revoke/send-* 동작 시 audit 로그 확인.
JL James Lee이(가) 6달 전에 변경
후속 — 이메일 본문 템플릿화 (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 --lib55 passed (다중 inline 신규 test 1개) -
cargo clippy --all-targetsclean
향후 확장 가능성¶
- 이메일 다국어 (en/ko 등) —
{{ lang }}분기 또는 별도 템플릿 파일 - 발송 시점 변수 —
{{ now }}/{{ user_name }}등 추가 - 다른 이메일 (welcome / reminder 등) — 같은 pattern 으로 별도 템플릿 파일 추가