Add SNS Marketing Campaign feature

- Add cash_cc_balance to User model (withdrawable CC)
- Create SnsShareSubmission model for SNS share verification
- Add marketing campaign settings to SystemSettings
- Add reward_type to ReferralReward model
- Create /api/sns-share endpoints for submission and verification
- Add referral signup reward logic (10CC on signup)
- Create /sns-share user page for SNS sharing
- Create /admin/sns-shares management page
- Add marketing settings UI to admin settings page
- Add SNS Shares menu to admin sidebar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-01-03 18:21:17 +09:00
parent 718c5b0474
commit 7c943d8553
17 changed files with 1414 additions and 6 deletions

View File

@@ -7,7 +7,7 @@ from pydantic import BaseModel
from typing import Optional
import bcrypt
from ..database import get_db
from ..models import User
from ..models import User, SystemSettings, ReferralReward
from ..models.user import generate_referral_code
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
from ..config import get_settings
@@ -151,6 +151,32 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)):
db.add(user)
db.commit()
db.refresh(user)
# 레퍼럴 가입 보상 처리
if user.referred_by:
# 추천인 찾기
referrer = db.query(User).filter(User.referral_code == user.referred_by).first()
if referrer:
# 설정에서 보상 CC 가져오기
sys_settings = db.query(SystemSettings).first()
reward_cc = sys_settings.referral_signup_reward_cc if sys_settings else 10.0
# 추천인에게 CC 지급 (프로모션 CC - 출금 불가)
referrer.cc_balance += reward_cc
# 레퍼럴 보상 기록 생성
referral_reward = ReferralReward(
referrer_id=referrer.id,
referred_user_id=user.id,
reward_type="signup",
payment_amount=0.0,
reward_amount=reward_cc,
status="credited",
credited_at=datetime.utcnow(),
)
db.add(referral_reward)
db.commit()
return user

View File

@@ -0,0 +1,277 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
from datetime import datetime
from ..database import get_db
from ..models import User, Car, SnsShareSubmission, SystemSettings
from ..schemas.sns_share import (
SnsShareSubmit, SnsShareResponse, SnsShareListResponse,
SnsShareVerify, SnsShareStats
)
from .auth import get_current_user, get_current_admin_user
router = APIRouter(prefix="/sns-share", tags=["SNS Share"])
def get_car_info(car: Car) -> dict:
"""차량 정보 추출"""
car_name = f"{car.maker_name or ''} {car.model_name or ''} {car.grade_name or ''}".strip()
car_image = None
if car.images and len(car.images) > 0:
car_image = car.images[0].image_url
return {"car_name": car_name, "car_image": car_image}
def submission_to_response(submission: SnsShareSubmission) -> SnsShareResponse:
"""SnsShareSubmission을 Response로 변환"""
car_info = get_car_info(submission.car) if submission.car else {}
return SnsShareResponse(
id=submission.id,
user_id=submission.user_id,
car_id=submission.car_id,
platform=submission.platform,
sns_url=submission.sns_url,
status=submission.status,
rejected_reason=submission.rejected_reason,
reward_cc=submission.reward_cc,
rewarded_at=submission.rewarded_at,
submitted_at=submission.submitted_at,
verified_at=submission.verified_at,
verified_by=submission.verified_by,
car_name=car_info.get("car_name"),
car_image=car_info.get("car_image"),
)
@router.post("/submit", response_model=SnsShareResponse)
async def submit_sns_share(
data: SnsShareSubmit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""SNS 공유 URL 제출"""
# 마케팅 캠페인 활성화 확인
settings = db.query(SystemSettings).first()
if settings and not settings.marketing_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Marketing campaign is not active"
)
# 캠페인 기간 확인
now = datetime.utcnow()
if settings:
if settings.marketing_start_date and now < settings.marketing_start_date:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Marketing campaign has not started yet"
)
if settings.marketing_end_date and now > settings.marketing_end_date:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Marketing campaign has ended"
)
# 차량 존재 확인
car = db.query(Car).filter(Car.id == data.car_id).first()
if not car:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Car not found"
)
# 플랫폼 검증
valid_platforms = ["twitter", "instagram", "facebook"]
if data.platform.lower() not in valid_platforms:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"
)
# 중복 제출 확인 (같은 차량, 같은 URL)
existing = db.query(SnsShareSubmission).filter(
SnsShareSubmission.user_id == current_user.id,
SnsShareSubmission.car_id == data.car_id,
SnsShareSubmission.sns_url == data.sns_url
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You have already submitted this URL for this car"
)
# 보상 CC 설정
reward_cc = settings.sns_share_reward_cc if settings else 3.0
# 제출 생성
submission = SnsShareSubmission(
user_id=current_user.id,
car_id=data.car_id,
platform=data.platform.lower(),
sns_url=data.sns_url,
status="pending",
reward_cc=reward_cc,
)
db.add(submission)
db.commit()
db.refresh(submission)
return submission_to_response(submission)
@router.get("/my-submissions", response_model=SnsShareListResponse)
async def get_my_submissions(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""내 SNS 공유 제출 목록"""
submissions = db.query(SnsShareSubmission).filter(
SnsShareSubmission.user_id == current_user.id
).order_by(SnsShareSubmission.submitted_at.desc()).all()
pending_count = len([s for s in submissions if s.status == "pending"])
approved_count = len([s for s in submissions if s.status == "approved"])
rejected_count = len([s for s in submissions if s.status == "rejected"])
return SnsShareListResponse(
submissions=[submission_to_response(s) for s in submissions],
total=len(submissions),
pending_count=pending_count,
approved_count=approved_count,
rejected_count=rejected_count,
)
@router.get("/admin/list", response_model=SnsShareListResponse)
async def admin_get_submissions(
status_filter: Optional[str] = None,
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin_user)
):
"""관리자: 전체 SNS 공유 제출 목록"""
query = db.query(SnsShareSubmission)
if status_filter:
query = query.filter(SnsShareSubmission.status == status_filter)
total = query.count()
submissions = query.order_by(SnsShareSubmission.submitted_at.desc()).offset(skip).limit(limit).all()
# 전체 통계
pending_count = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "pending").count()
approved_count = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "approved").count()
rejected_count = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "rejected").count()
return SnsShareListResponse(
submissions=[submission_to_response(s) for s in submissions],
total=total,
pending_count=pending_count,
approved_count=approved_count,
rejected_count=rejected_count,
)
@router.get("/admin/stats", response_model=SnsShareStats)
async def admin_get_stats(
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin_user)
):
"""관리자: SNS 공유 통계"""
total = db.query(SnsShareSubmission).count()
pending = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "pending").count()
approved = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "approved").count()
rejected = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "rejected").count()
total_rewarded = db.query(func.sum(SnsShareSubmission.reward_cc)).filter(
SnsShareSubmission.status == "approved"
).scalar() or 0.0
return SnsShareStats(
total_submissions=total,
pending_submissions=pending,
approved_submissions=approved,
rejected_submissions=rejected,
total_rewarded_cc=total_rewarded,
)
@router.put("/admin/{submission_id}/verify", response_model=SnsShareResponse)
async def admin_verify_submission(
submission_id: int,
data: SnsShareVerify,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin_user)
):
"""관리자: SNS 공유 검증 (승인/거부)"""
submission = db.query(SnsShareSubmission).filter(
SnsShareSubmission.id == submission_id
).first()
if not submission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Submission not found"
)
if submission.status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Submission has already been verified"
)
now = datetime.utcnow()
if data.approved:
# 승인: CC 지급
submission.status = "approved"
submission.verified_at = now
submission.verified_by = admin.id
submission.rewarded_at = now
# 사용자 CC 잔액 업데이트 (프로모션 CC - 출금 불가)
user = db.query(User).filter(User.id == submission.user_id).first()
if user:
user.cc_balance += submission.reward_cc
else:
# 거부
submission.status = "rejected"
submission.verified_at = now
submission.verified_by = admin.id
submission.rejected_reason = data.rejected_reason
db.commit()
db.refresh(submission)
return submission_to_response(submission)
@router.get("/campaign-status")
async def get_campaign_status(db: Session = Depends(get_db)):
"""마케팅 캠페인 상태 조회 (공개)"""
settings = db.query(SystemSettings).first()
if not settings:
return {
"enabled": False,
"message": "Campaign not configured"
}
now = datetime.utcnow()
is_active = settings.marketing_enabled
if settings.marketing_start_date and now < settings.marketing_start_date:
is_active = False
if settings.marketing_end_date and now > settings.marketing_end_date:
is_active = False
return {
"enabled": is_active,
"start_date": settings.marketing_start_date,
"end_date": settings.marketing_end_date,
"sns_share_reward_cc": settings.sns_share_reward_cc,
"referral_signup_reward_cc": settings.referral_signup_reward_cc,
}

View File

@@ -9,7 +9,7 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from .database import engine, Base, SessionLocal
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share
from .config import get_settings
from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
@@ -201,6 +201,7 @@ app.include_router(push.router, prefix="/api")
app.include_router(exchange_rate.router)
app.include_router(verification.router, prefix="/api")
app.include_router(visitor.router, prefix="/api")
app.include_router(sns_share.router, prefix="/api")
@app.get("/")

View File

@@ -11,6 +11,7 @@ from .vehicle_share import VehicleShare, ShareReward
from .withdrawal import WithdrawalRequest
from .referral import ReferralReward
from .notification import Notification
from .sns_share import SnsShareSubmission
from .push_subscription import PushSubscription, UserNotificationPreference
from .performance_check import CarPerformanceCheck
from .car_specification import CarSpecification
@@ -52,6 +53,7 @@ __all__ = [
"WithdrawalRequest",
"ReferralReward",
"Notification",
"SnsShareSubmission",
"PushSubscription",
"UserNotificationPreference",
"ExchangeRate",

View File

@@ -16,10 +16,13 @@ class ReferralReward(Base):
# 피추천인 (추천받아 가입한 사람)
referred_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# 결제 금액 (피추천인이 충전한 금액 USD)
payment_amount = Column(Float, nullable=False)
# 보상 유형: "signup" (가입 시), "payment" (결제 시)
reward_type = Column(String(20), default="payment")
# 보상 금액 (결제 금액의 X%)
# 결제 금액 (피추천인이 충전한 금액 USD, signup인 경우 0)
payment_amount = Column(Float, default=0.0)
# 보상 금액 (결제 금액의 X% 또는 고정 CC)
reward_amount = Column(Float, nullable=False)
# 보상 상태: pending(대기), credited(적립), withdrawn(출금)

View File

@@ -47,6 +47,22 @@ class SystemSettings(Base):
exchange_rate_weight_rub = Column(Float, default=0.0) # RUB (러시아 루블) 가중치
exchange_rate_weight_cny = Column(Float, default=0.0) # CNY (중국 위안) 가중치
# 마케팅 캠페인 설정
marketing_enabled = Column(Boolean, default=False) # 마케팅 캠페인 활성화
marketing_start_date = Column(DateTime(timezone=True), nullable=True) # 캠페인 시작일
marketing_end_date = Column(DateTime(timezone=True), nullable=True) # 캠페인 종료일
# SNS 공유 보상 설정
sns_share_reward_cc = Column(Float, default=3.0) # SNS 공유 보상 CC (기본 3CC)
referral_signup_reward_cc = Column(Float, default=10.0) # 레퍼럴 가입 보상 CC (기본 10CC)
# 이벤트 CC 유효기간 (개월)
event_cc_validity_months = Column(Integer, default=6) # 이벤트 CC 유효기간 (기본 6개월)
# 출금 설정
withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화
min_withdrawal_usd = Column(Float, default=10.0) # 최소 출금 금액 (USD)
# 타임스탬프
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,35 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class SnsShareSubmission(Base):
"""SNS 공유 제출 및 검증 모델"""
__tablename__ = "sns_share_submissions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
car_id = Column(Integer, ForeignKey("cars.id"), nullable=False)
# SNS 정보
platform = Column(String(20), nullable=False) # twitter, instagram, facebook
sns_url = Column(String(500), nullable=False) # 공유 게시물 URL
# 상태
status = Column(String(20), default="pending") # pending, approved, rejected
rejected_reason = Column(Text, nullable=True) # 거부 사유
# CC 보상
reward_cc = Column(Float, default=3.0) # 기본 3CC 보상
rewarded_at = Column(DateTime(timezone=True), nullable=True) # 보상 지급 시각
# 타임스탬프
submitted_at = Column(DateTime(timezone=True), server_default=func.now())
verified_at = Column(DateTime(timezone=True), nullable=True) # 검증 시각
verified_by = Column(Integer, ForeignKey("users.id"), nullable=True) # 검증한 관리자
# Relationships
user = relationship("User", foreign_keys=[user_id], backref="sns_submissions")
car = relationship("Car", backref="sns_shares")
verifier = relationship("User", foreign_keys=[verified_by])

View File

@@ -24,7 +24,8 @@ class User(Base):
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
is_dealer = Column(Boolean, default=False) # Dealer status
cc_balance = Column(Float, default=3.0) # CC coin balance, 3 free on signup
cc_balance = Column(Float, default=3.0) # 프로모션 CC (출금 불가) - 가입보너스, SNS공유, 레퍼럴
cash_cc_balance = Column(Float, default=0.0) # 현금성 CC (출금 가능) - 딜러수수료 88%
referral_code = Column(String(8), unique=True, index=True) # Unique referral code for sharing
referred_by = Column(String(8), nullable=True) # Referral code of the user who referred this user

View File

@@ -47,6 +47,10 @@ from .notification import (
NotificationCreate, NotificationResponse,
NotificationListResponse, NotificationMarkRead,
)
from .sns_share import (
SnsShareSubmit, SnsShareResponse, SnsShareListResponse,
SnsShareVerify, SnsShareStats,
)
__all__ = [
"CarMakerCreate", "CarMakerResponse",
@@ -78,4 +82,6 @@ __all__ = [
"ReferralSettingsResponse", "ReferralSettingsUpdate",
"NotificationCreate", "NotificationResponse",
"NotificationListResponse", "NotificationMarkRead",
"SnsShareSubmit", "SnsShareResponse", "SnsShareListResponse",
"SnsShareVerify", "SnsShareStats",
]

View File

@@ -22,6 +22,16 @@ class SystemSettingsUpdate(BaseModel):
referral_reward_percent: Optional[float] = None
referral_reward_type: Optional[str] = None
# 마케팅 캠페인 설정
marketing_enabled: Optional[bool] = None
marketing_start_date: Optional[datetime] = None
marketing_end_date: Optional[datetime] = None
sns_share_reward_cc: Optional[float] = None
referral_signup_reward_cc: Optional[float] = None
event_cc_validity_months: Optional[int] = None
withdrawal_enabled: Optional[bool] = None
min_withdrawal_usd: Optional[float] = None
class SystemSettingsResponse(BaseModel):
"""시스템 설정 응답 스키마"""
@@ -42,6 +52,17 @@ class SystemSettingsResponse(BaseModel):
referral_reward_enabled: bool = True
referral_reward_percent: float = 10.0
referral_reward_type: str = "one_time"
# 마케팅 캠페인 설정
marketing_enabled: bool = False
marketing_start_date: Optional[datetime] = None
marketing_end_date: Optional[datetime] = None
sns_share_reward_cc: float = 3.0
referral_signup_reward_cc: float = 10.0
event_cc_validity_months: int = 6
withdrawal_enabled: bool = True
min_withdrawal_usd: float = 10.0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@@ -0,0 +1,57 @@
from pydantic import BaseModel, HttpUrl
from typing import Optional, List
from datetime import datetime
class SnsShareSubmit(BaseModel):
"""SNS 공유 제출 요청"""
car_id: int
platform: str # twitter, instagram, facebook
sns_url: str # 공유 게시물 URL
class SnsShareResponse(BaseModel):
"""SNS 공유 제출 응답"""
id: int
user_id: int
car_id: int
platform: str
sns_url: str
status: str
rejected_reason: Optional[str] = None
reward_cc: float
rewarded_at: Optional[datetime] = None
submitted_at: datetime
verified_at: Optional[datetime] = None
verified_by: Optional[int] = None
# Car info for display
car_name: Optional[str] = None
car_image: Optional[str] = None
class Config:
from_attributes = True
class SnsShareListResponse(BaseModel):
"""SNS 공유 목록 응답"""
submissions: List[SnsShareResponse]
total: int
pending_count: int
approved_count: int
rejected_count: int
class SnsShareVerify(BaseModel):
"""관리자 검증 요청"""
approved: bool
rejected_reason: Optional[str] = None
class SnsShareStats(BaseModel):
"""SNS 공유 통계"""
total_submissions: int
pending_submissions: int
approved_submissions: int
rejected_submissions: int
total_rewarded_cc: float

View File

@@ -0,0 +1,45 @@
-- Marketing Campaign Migration V1
-- Apply to staging/production PostgreSQL databases
--
-- Instructions:
-- 1. Connect to staging: psql -U autonet -d autonet_staging
-- 2. Run: \i /path/to/marketing_campaign_v1.sql
--
-- 1. Add cash_cc_balance to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS cash_cc_balance FLOAT DEFAULT 0.0;
-- 2. Create sns_share_submissions table
CREATE TABLE IF NOT EXISTS sns_share_submissions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
car_id INTEGER NOT NULL REFERENCES cars(id),
platform VARCHAR(20) NOT NULL,
sns_url VARCHAR(500) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
rejected_reason TEXT,
reward_cc FLOAT DEFAULT 3.0,
rewarded_at TIMESTAMP WITH TIME ZONE,
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
verified_at TIMESTAMP WITH TIME ZONE,
verified_by INTEGER REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS ix_sns_share_submissions_user_id ON sns_share_submissions(user_id);
CREATE INDEX IF NOT EXISTS ix_sns_share_submissions_status ON sns_share_submissions(status);
-- 3. Add marketing settings to system_settings table
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS marketing_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS marketing_start_date TIMESTAMP WITH TIME ZONE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS marketing_end_date TIMESTAMP WITH TIME ZONE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS sns_share_reward_cc FLOAT DEFAULT 3.0;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS referral_signup_reward_cc FLOAT DEFAULT 10.0;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS event_cc_validity_months INTEGER DEFAULT 6;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS withdrawal_enabled BOOLEAN DEFAULT TRUE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_withdrawal_usd FLOAT DEFAULT 10.0;
-- 4. Add reward_type to referral_rewards table
ALTER TABLE referral_rewards ADD COLUMN IF NOT EXISTS reward_type VARCHAR(20) DEFAULT 'payment';
-- Done
SELECT 'Marketing Campaign V1 migration completed!' AS status;