Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/shared/shared/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,13 @@ class PlanetID(bytes, Enum):
HEIMDALL_INTERNAL = b"0x100000000001"
IDUN_INTERNAL = b"0x100000000002"
THOR_INTERNAL = b"0x100000000003"


class VoucherGrantStatus(IntEnum):
"""(PLD-1468) NCG Voucher 아웃박스 상태 — 포탈 발급/회수 멱등·재시도 추적."""

PENDING = 0 # grant 대기/진행
GRANTED = 1 # 포탈 grant 성공
REVOKE_PENDING = 2 # 환불 감지, revoke 대기
REVOKED = 3 # 포탈 revoke 성공
FAILED = 4 # 재시도 소진(수동 개입)
2 changes: 2 additions & 0 deletions apps/shared/shared/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
"receipt",
"product",
"voucher",
"voucher_grant_outbox",
"product_voucher_grant",
"user",
]
34 changes: 34 additions & 0 deletions apps/shared/shared/models/product_voucher_grant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Integer,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, relationship

from shared.models.base import AutoIdMixin, Base, TimeStampMixin
from shared.models.product import Product


class ProductVoucherGrant(AutoIdMixin, TimeStampMixin, Base):
"""
(PLD-1472) 상품 → NCG Voucher(복권) 티켓 매핑.

상품이 결제되면 어떤 `ticket_type`(포탈 prizeTables 키: STANDARD/PREMIUM/…)을 몇 장 발급할지.
한 상품이 여러 종류를 줄 수 있어 (product_id, ticket_type) 별 1행. `active=false`면 발급 제외.
(IAP는 발급 수량만 정하고, 상금표·확률·개봉은 포탈 voucher_policy가 권위.)
"""

__tablename__ = "product_voucher_grant"

product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
product: Mapped["Product"] = relationship("Product", foreign_keys=[product_id])
ticket_type = Column(Text, nullable=False, doc="포탈 prizeTables 키")
count = Column(Integer, nullable=False, default=1, server_default="1")
active = Column(Boolean, nullable=False, default=True, server_default="true")

__table_args__ = (
UniqueConstraint("product_id", "ticket_type", name="uq_product_voucher_grant"),
)
47 changes: 47 additions & 0 deletions apps/shared/shared/models/voucher_grant_outbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, backref, relationship

from shared.enums import VoucherGrantStatus
from shared.models.base import AutoIdMixin, Base, EnumType, TimeStampMixin
from shared.models.receipt import Receipt


class VoucherGrantOutbox(AutoIdMixin, TimeStampMixin, Base):
"""
(PLD-1468) NCG Voucher 아웃박스 — 검증된 결제(Receipt)에 대한 포탈 바우처 발급/회수의 멱등·재시도 추적.

- receipt_id UNIQUE = 1 결제 = 1 행 (멱등).
- 지급 트리거(worker)가 포탈 grant 호출 후 status=GRANTED. 실패는 attempts++/last_error 기록 후 재시도.
- 리컨사일(beat)이 cutoff 이후 VALID인데 여기 GRANTED가 없는 receipt를 찾아 재호출(포탈 멱등).
- 환불 감지 시 REVOKE_PENDING → 포탈 revoke 성공 후 REVOKED.

권위 있는 바우처 상태(등급/개봉/홀드/회수)는 포탈 `purchase_voucher`에 있고,
이 테이블은 IAP측 "포탈에 넘겼나?" 아웃박스 마커일 뿐이다. (고아 `voucher_request` 재사용 대신 신규 — 옛 스키마 의미·스테일 회피)
"""

__tablename__ = "voucher_grant_outbox"

receipt_id = Column(Integer, ForeignKey("receipt.id"), nullable=False, unique=True)
receipt: Mapped["Receipt"] = relationship(
"Receipt",
foreign_keys=[receipt_id],
uselist=False,
backref=backref("voucher_grant_outbox"),
)

status = Column(
EnumType(VoucherGrantStatus),
nullable=False,
default=VoucherGrantStatus.PENDING,
server_default=str(VoucherGrantStatus.PENDING.value), # "0" — bulk/upsert/raw insert도 NOT NULL 안전
)
portal_ref = Column(Text, nullable=True, doc="포탈 grant 응답 참조(멱등 확인용)")
attempts = Column(Integer, nullable=False, default=0, server_default="0")
last_error = Column(Text, nullable=True)
granted_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True)

__table_args__ = (
# 재시도 워커가 미완료(status != GRANTED) 행을 폴링 → status 인덱스.
Index("ix_voucher_grant_outbox_status", "status"),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Add voucher_grant_outbox table (PLD-1468)

Revision ID: 8db0d254c89d
Revises: b1d5e1dc71ea
Create Date: 2026-08-03 00:00:00

"""
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "8db0d254c89d"
down_revision = "b1d5e1dc71ea"
branch_labels = None
depends_on = None


def upgrade() -> None:
# (PLD-1468) NCG Voucher 아웃박스. status는 EnumType(VoucherGrantStatus) = Integer 백엔드.
op.create_table(
"voucher_grant_outbox",
sa.Column("receipt_id", sa.Integer(), nullable=False),
sa.Column("status", sa.Integer(), server_default="0", nullable=False),
sa.Column("portal_ref", sa.Text(), nullable=True),
sa.Column("attempts", sa.Integer(), server_default="0", nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["receipt_id"], ["receipt.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("receipt_id", name="uq_voucher_grant_outbox_receipt_id"),
)
op.create_index(
"ix_voucher_grant_outbox_status", "voucher_grant_outbox", ["status"], unique=False
)


def downgrade() -> None:
op.drop_index("ix_voucher_grant_outbox_status", table_name="voucher_grant_outbox")
op.drop_table("voucher_grant_outbox")
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Add product_voucher_grant table (PLD-1472)

Revision ID: 9c1e2f3a4b5c
Revises: 8db0d254c89d
Create Date: 2026-08-04 00:00:00

"""
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "9c1e2f3a4b5c"
down_revision = "8db0d254c89d"
branch_labels = None
depends_on = None


def upgrade() -> None:
# (PLD-1472) 상품 → 복권 티켓 매핑. (product_id, ticket_type) 별 1행, active=false면 발급 제외.
op.create_table(
"product_voucher_grant",
sa.Column("product_id", sa.Integer(), nullable=False),
sa.Column("ticket_type", sa.Text(), nullable=False),
sa.Column("count", sa.Integer(), server_default="1", nullable=False),
sa.Column("active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["product_id"], ["product.id"]),
sa.PrimaryKeyConstraint("id"),
# UNIQUE(product_id, ticket_type) 복합 btree가 product_id 선두 조회를 커버 → 별도 단일 인덱스 불요.
sa.UniqueConstraint("product_id", "ticket_type", name="uq_product_voucher_grant"),
)


def downgrade() -> None:
op.drop_table("product_voucher_grant")
10 changes: 10 additions & 0 deletions apps/worker/app/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
"schedule": crontab(minute="*/60"),
"options": {"queue": "background_job_queue"},
},
"voucher-grant-every-2-minutes": {
"task": "iap.voucher_grant",
"schedule": crontab(minute="*/2"),
"options": {"queue": "background_job_queue"},
},
"voucher-reconcile-every-5-minutes": {
"task": "iap.voucher_reconcile",
"schedule": crontab(minute="*/5"),
"options": {"queue": "background_job_queue"},
},
}

app.conf.update(
Expand Down
9 changes: 9 additions & 0 deletions apps/worker/app/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
from datetime import datetime
from typing import Optional

from pydantic import AmqpDsn, PostgresDsn, RedisDsn
Expand Down Expand Up @@ -26,6 +27,14 @@ class Settings(BaseSettings):
iap_alert_webhook_url: Optional[str] = None
iap_sales_webhook_url: Optional[str] = None

# NCG Voucher 지급 트리거(PLD-1469). 포탈 grant 엔드포인트 + 서버간 JWT(gameBackendApiHandler).
portal_grant_url: Optional[str] = None # 예: https://.../api/voucher/grant
portal_revoke_url: Optional[str] = None # 환불 회수용(PLD-1470)
portal_iap_jwt_secret: Optional[str] = None # 포탈 JWT_IAP_SECRET_KEY와 동일(HS256)
voucher_grant_enabled: bool = False # IAP측 마스터 스위치(포탈 policy.enabled와 별개)
# 이 시각(created_at) 이후 영수증만 바우처 대상(과거 소급 방지). ISO8601 env, 미설정(None)=컷오프 없음.
voucher_grant_cutoff: Optional[datetime] = None

google_credential: Optional[str] = None
google_package_dict: dict[PackageName, str] = {
PackageName.NINE_CHRONICLES_M: "com.planetariumlabs.ninechroniclesmobile",
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/app/tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
from app.tasks.status_monitor import status_monitor
from app.tasks.track_google_refund import track_google_refund
from app.tasks.tracker import track_tx
from app.tasks.voucher_grant_task import grant_vouchers
from app.tasks.voucher_reconcile_task import reconcile_vouchers
13 changes: 13 additions & 0 deletions apps/worker/app/tasks/track_google_refund.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from app.celery_app import app
from app.config import config
from app.tasks.voucher_reconcile_task import enqueue_revoke_by_order_ids

logger = structlog.get_logger(__name__)

Expand Down Expand Up @@ -74,6 +75,8 @@ def handle(event, context):
current_time = datetime.now(timezone.utc)
one_hour_ago = current_time - timedelta(hours=1)

refunded_order_ids: list[str] = [] # (PLD-1470) 바우처 회수 큐잉용

start_time_ms = int(one_hour_ago.timestamp() * 1000)
end_time_ms = int(current_time.timestamp() * 1000)

Expand Down Expand Up @@ -121,11 +124,21 @@ def handle(event, context):
logger.info(
f"환불 알림 전송: {void.orderId} (환불 시간: {void.voidedTime.isoformat()})"
)
refunded_order_ids.append(void.orderId)

logger.info(
f"{package_name.value} 패키지에서 {len(voided_purchases)}개의 최근 환불 데이터를 처리했습니다."
)

# (PLD-1470) 환불된 결제의 NCG 바우처 회수 큐잉(아웃박스 REVOKE_PENDING). 알림 흐름과 독립·best-effort.
# google buyer 환불은 receipt.status를 갱신하지 않으므로 이 훅이 유일 신호원.
try:
queued = enqueue_revoke_by_order_ids(refunded_order_ids)
if queued:
logger.info(f"바우처 회수 큐잉 {queued}건")
except Exception as e: # noqa: BLE001
logger.warning(f"바우처 회수 큐잉 실패(알림은 정상): {e}")


@app.task(
name="iap.track_google_refund",
Expand Down
Loading
Loading