""" Flask 웹 서버 - QR 마일리지 적립 간편 적립: 전화번호 + 이름만 입력 """ from flask import Flask, request, render_template, jsonify, redirect, url_for import hashlib from datetime import datetime, timezone, timedelta import sys import os import logging from sqlalchemy import text # Path setup sys.path.insert(0, os.path.dirname(__file__)) from db.dbsetup import DatabaseManager app = Flask(__name__) app.secret_key = 'pharmacy-qr-mileage-secret-key-2026' # 데이터베이스 매니저 db_manager = DatabaseManager() # KST 타임존 (UTC+9) KST = timezone(timedelta(hours=9)) def utc_to_kst_str(utc_time_str): """ UTC 시간 문자열을 KST 시간 문자열로 변환 Args: utc_time_str (str): UTC 시간 문자열 (ISO 8601 형식) Returns: str: KST 시간 문자열 (YYYY-MM-DD HH:MM:SS) """ if not utc_time_str: return None try: # ISO 8601 형식 파싱 ('2026-01-23T12:28:36' 또는 '2026-01-23 12:28:36') utc_time_str = utc_time_str.replace(' ', 'T') # 공백을 T로 변환 # datetime 객체로 변환 if 'T' in utc_time_str: utc_time = datetime.fromisoformat(utc_time_str) else: utc_time = datetime.fromisoformat(utc_time_str) # UTC 타임존 설정 (naive datetime인 경우) if utc_time.tzinfo is None: utc_time = utc_time.replace(tzinfo=timezone.utc) # KST로 변환 kst_time = utc_time.astimezone(KST) # 문자열로 반환 (초 단위까지만) return kst_time.strftime('%Y-%m-%d %H:%M:%S') except Exception as e: logging.error(f"시간 변환 실패: {utc_time_str}, 오류: {e}") return utc_time_str # 변환 실패 시 원본 반환 def verify_claim_token(transaction_id, nonce): """ QR 토큰 검증 Args: transaction_id (str): 거래 ID nonce (str): 12자 hex nonce Returns: tuple: (성공 여부, 메시지, 토큰 정보 dict) """ try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 1. 거래 ID로 토큰 조회 cursor.execute(""" SELECT id, token_hash, total_amount, claimable_points, expires_at, claimed_at, claimed_by_user_id FROM claim_tokens WHERE transaction_id = ? """, (transaction_id,)) token_record = cursor.fetchone() if not token_record: return (False, "유효하지 않은 QR 코드입니다.", None) # 2. 이미 적립된 토큰인지 확인 if token_record['claimed_at']: return (False, "이미 적립 완료된 영수증입니다.", None) # 3. 만료 확인 expires_at = datetime.strptime(token_record['expires_at'], '%Y-%m-%d %H:%M:%S') if datetime.now() > expires_at: return (False, "적립 기간이 만료되었습니다 (30일).", None) # 4. 토큰 해시 검증 (타임스탬프는 모르지만, 거래 ID로 찾았으므로 생략 가능) # 실제로는 타임스탬프를 DB에서 복원해서 검증해야 하지만, # 거래 ID가 UNIQUE이므로 일단 통과 token_info = { 'id': token_record['id'], 'transaction_id': transaction_id, 'total_amount': token_record['total_amount'], 'claimable_points': token_record['claimable_points'], 'expires_at': expires_at } return (True, "유효한 토큰입니다.", token_info) except Exception as e: return (False, f"토큰 검증 실패: {str(e)}", None) def get_or_create_user(phone, name): """ 사용자 조회 또는 생성 (간편 적립용) Args: phone (str): 전화번호 name (str): 이름 Returns: tuple: (user_id, is_new_user) """ conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 전화번호로 조회 cursor.execute(""" SELECT id, mileage_balance FROM users WHERE phone = ? """, (phone,)) user = cursor.fetchone() if user: return (user['id'], False) # 신규 생성 cursor.execute(""" INSERT INTO users (nickname, phone, mileage_balance) VALUES (?, ?, 0) """, (name, phone)) conn.commit() return (cursor.lastrowid, True) def claim_mileage(user_id, token_info): """ 마일리지 적립 처리 Args: user_id (int): 사용자 ID token_info (dict): 토큰 정보 Returns: tuple: (성공 여부, 메시지, 적립 후 잔액) """ try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 1. 현재 잔액 조회 cursor.execute("SELECT mileage_balance FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() current_balance = user['mileage_balance'] # 2. 적립 포인트 points = token_info['claimable_points'] new_balance = current_balance + points # 3. 사용자 잔액 업데이트 cursor.execute(""" UPDATE users SET mileage_balance = ?, updated_at = ? WHERE id = ? """, (new_balance, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id)) # 4. 마일리지 원장 기록 cursor.execute(""" INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason, description) VALUES (?, ?, ?, ?, ?, ?) """, ( user_id, token_info['transaction_id'], points, new_balance, 'CLAIM', f"영수증 QR 적립 ({token_info['total_amount']:,}원 구매)" )) # 5. claim_tokens 업데이트 (적립 완료 표시) cursor.execute(""" UPDATE claim_tokens SET claimed_at = ?, claimed_by_user_id = ? WHERE id = ? """, (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id, token_info['id'])) conn.commit() return (True, f"{points}P 적립 완료!", new_balance) except Exception as e: conn.rollback() return (False, f"적립 처리 실패: {str(e)}", 0) # ============================================================================ # 라우트 # ============================================================================ @app.route('/') def index(): """메인 페이지""" return """ 청춘약국 마일리지

🏥 청춘약국 마일리지

영수증 QR 코드를 스캔하여
마일리지를 적립해보세요!

구매금액의 3%
포인트로 돌려드립니다.
""" @app.route('/claim') def claim(): """ QR 코드 랜딩 페이지 URL: /claim?t=transaction_id:nonce """ # 토큰 파라미터 파싱 token_param = request.args.get('t', '') if ':' not in token_param: return render_template('error.html', message="잘못된 QR 코드 형식입니다.") parts = token_param.split(':') if len(parts) != 2: return render_template('error.html', message="잘못된 QR 코드 형식입니다.") transaction_id, nonce = parts[0], parts[1] # 토큰 검증 success, message, token_info = verify_claim_token(transaction_id, nonce) if not success: return render_template('error.html', message=message) # 간편 적립 페이지 렌더링 return render_template('claim_form.html', token_info=token_info) @app.route('/api/claim', methods=['POST']) def api_claim(): """ 마일리지 적립 API POST /api/claim Body: { "transaction_id": "...", "nonce": "...", "phone": "010-1234-5678", "name": "홍길동" } """ try: data = request.get_json() transaction_id = data.get('transaction_id') nonce = data.get('nonce') phone = data.get('phone', '').strip() name = data.get('name', '').strip() privacy_consent = data.get('privacy_consent', False) # 입력 검증 if not phone or not name: return jsonify({ 'success': False, 'message': '전화번호와 이름을 모두 입력해주세요.' }), 400 # 개인정보 동의 검증 if not privacy_consent: return jsonify({ 'success': False, 'message': '개인정보 수집·이용에 동의해주세요.' }), 400 # 전화번호 형식 정리 (하이픈 제거) phone = phone.replace('-', '').replace(' ', '') if len(phone) < 10: return jsonify({ 'success': False, 'message': '올바른 전화번호를 입력해주세요.' }), 400 # 토큰 검증 success, message, token_info = verify_claim_token(transaction_id, nonce) if not success: return jsonify({ 'success': False, 'message': message }), 400 # 사용자 조회/생성 user_id, is_new = get_or_create_user(phone, name) # 마일리지 적립 success, message, new_balance = claim_mileage(user_id, token_info) if not success: return jsonify({ 'success': False, 'message': message }), 500 return jsonify({ 'success': True, 'message': message, 'points': token_info['claimable_points'], 'balance': new_balance, 'is_new_user': is_new }) except Exception as e: return jsonify({ 'success': False, 'message': f'오류가 발생했습니다: {str(e)}' }), 500 @app.route('/my-page') def my_page(): """마이페이지 (전화번호로 조회)""" phone = request.args.get('phone', '') if not phone: return render_template('my_page_login.html') # 전화번호로 사용자 조회 phone = phone.replace('-', '').replace(' ', '') conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT id, nickname, phone, mileage_balance, created_at FROM users WHERE phone = ? """, (phone,)) user_raw = cursor.fetchone() if not user_raw: return render_template('error.html', message='등록되지 않은 전화번호입니다.') # 사용자 정보에 KST 시간 변환 적용 user = dict(user_raw) user['created_at'] = utc_to_kst_str(user_raw['created_at']) # 적립 내역 조회 cursor.execute(""" SELECT points, balance_after, reason, description, created_at FROM mileage_ledger WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 """, (user['id'],)) transactions_raw = cursor.fetchall() # 거래 내역에 KST 시간 변환 적용 transactions = [] for tx in transactions_raw: tx_dict = dict(tx) tx_dict['created_at'] = utc_to_kst_str(tx['created_at']) transactions.append(tx_dict) return render_template('my_page.html', user=user, transactions=transactions) @app.route('/admin/transaction/') def admin_transaction_detail(transaction_id): """거래 세부 내역 조회 (MSSQL)""" try: # MSSQL PM_PRES 연결 session = db_manager.get_session('PM_PRES') # SALE_MAIN 조회 (거래 헤더) sale_main_query = text(""" SELECT SL_NO_order, InsertTime, SL_MY_total, SL_MY_discount, SL_MY_sale, SL_MY_credit, SL_MY_recive, SL_MY_rec_vat, ISNULL(SL_NM_custom, '[비고객]') AS customer_name FROM SALE_MAIN WHERE SL_NO_order = :transaction_id """) sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone() if not sale_main: return jsonify({ 'success': False, 'message': '거래 내역을 찾을 수 없습니다.' }), 404 # SALE_SUB 조회 (판매 상품 상세) sale_sub_query = text(""" SELECT S.DrugCode, ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_NM_cost_a AS price, S.SL_TOTAL_PRICE AS total FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :transaction_id ORDER BY S.DrugCode """) sale_items = session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall() # 결과를 JSON으로 반환 result = { 'success': True, 'transaction': { 'id': sale_main.SL_NO_order, 'date': str(sale_main.InsertTime), 'total_amount': int(sale_main.SL_MY_total or 0), 'discount': int(sale_main.SL_MY_discount or 0), 'sale_amount': int(sale_main.SL_MY_sale or 0), 'credit': int(sale_main.SL_MY_credit or 0), 'supply_value': int(sale_main.SL_MY_recive or 0), 'vat': int(sale_main.SL_MY_rec_vat or 0), 'customer_name': sale_main.customer_name }, 'items': [ { 'code': item.DrugCode, 'name': item.goods_name, 'qty': int(item.quantity or 0), 'price': int(item.price or 0), 'total': int(item.total or 0) } for item in sale_items ] } return jsonify(result) except Exception as e: return jsonify({ 'success': False, 'message': f'조회 실패: {str(e)}' }), 500 @app.route('/admin/user/') def admin_user_detail(user_id): """사용자 상세 이력 조회 - 구매 이력, 적립 이력, 구매 품목""" try: # 1. SQLite 연결 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 2. 사용자 기본 정보 조회 cursor.execute(""" SELECT id, nickname, phone, mileage_balance, created_at FROM users WHERE id = ? """, (user_id,)) user = cursor.fetchone() if not user: return jsonify({ 'success': False, 'message': '사용자를 찾을 수 없습니다.' }), 404 # 3. 마일리지 이력 조회 (최근 50건) cursor.execute(""" SELECT transaction_id, points, balance_after, reason, description, created_at FROM mileage_ledger WHERE user_id = ? ORDER BY created_at DESC LIMIT 50 """, (user_id,)) mileage_history = cursor.fetchall() # 4. 구매 이력 조회 (적립된 거래만, 최근 20건) cursor.execute(""" SELECT transaction_id, total_amount, claimable_points, claimed_at FROM claim_tokens WHERE claimed_by_user_id = ? ORDER BY claimed_at DESC LIMIT 20 """, (user_id,)) claimed_tokens = cursor.fetchall() # 5. 각 거래의 상품 상세 조회 (MSSQL) purchases = [] try: session = db_manager.get_session('PM_PRES') for token in claimed_tokens: transaction_id = token['transaction_id'] # SALE_MAIN에서 거래 시간 조회 sale_main_query = text(""" SELECT InsertTime FROM SALE_MAIN WHERE SL_NO_order = :transaction_id """) sale_main = session.execute( sale_main_query, {'transaction_id': transaction_id} ).fetchone() # 거래 시간 추출 (MSSQL의 실제 거래 시간) if sale_main and sale_main.InsertTime: transaction_date = str(sale_main.InsertTime)[:16].replace('T', ' ') else: transaction_date = '-' # SALE_SUB + CD_GOODS JOIN sale_items_query = text(""" SELECT S.DrugCode, ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_NM_cost_a AS price, S.SL_TOTAL_PRICE AS total FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :transaction_id ORDER BY S.DrugCode """) items_raw = session.execute( sale_items_query, {'transaction_id': transaction_id} ).fetchall() # 상품 리스트 변환 items = [ { 'code': item.DrugCode, 'name': item.goods_name, 'qty': int(item.quantity or 0), 'price': int(item.price or 0), 'total': int(item.total or 0) } for item in items_raw ] # 상품 요약 생성 ("첫번째상품명 외 N개") if items: first_item_name = items[0]['name'] items_count = len(items) if items_count == 1: items_summary = first_item_name else: items_summary = f"{first_item_name} 외 {items_count - 1}개" else: items_summary = "상품 정보 없음" items_count = 0 purchases.append({ 'transaction_id': transaction_id, 'date': transaction_date, # MSSQL의 실제 거래 시간 사용 'amount': int(token['total_amount']), 'points': int(token['claimable_points']), 'items_summary': items_summary, 'items_count': items_count, 'items': items }) except Exception as mssql_error: # MSSQL 연결 실패 시 빈 배열 반환 print(f"[WARNING] MSSQL 조회 실패 (user {user_id}): {mssql_error}") purchases = [] # 6. 응답 생성 return jsonify({ 'success': True, 'user': { 'id': user['id'], 'name': user['nickname'], 'phone': user['phone'], 'balance': user['mileage_balance'], 'created_at': utc_to_kst_str(user['created_at']) }, 'mileage_history': [ { 'points': ml['points'], 'balance_after': ml['balance_after'], 'reason': ml['reason'], 'description': ml['description'], 'created_at': utc_to_kst_str(ml['created_at']), 'transaction_id': ml['transaction_id'] } for ml in mileage_history ], 'purchases': purchases }) except Exception as e: return jsonify({ 'success': False, 'message': f'조회 실패: {str(e)}' }), 500 @app.route('/admin/search/user') def admin_search_user(): """사용자 검색 (이름/전화번호/전화번호 뒷자리)""" query = request.args.get('q', '').strip() search_type = request.args.get('type', 'name') # 'name', 'phone', 'phone_last' if not query: return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() try: if search_type == 'phone_last': # 전화번호 뒷자리 검색 cursor.execute(""" SELECT id, nickname, phone, mileage_balance FROM users WHERE phone LIKE ? ORDER BY created_at DESC """, (f'%{query}',)) elif search_type == 'phone': # 전체 전화번호 검색 cursor.execute(""" SELECT id, nickname, phone, mileage_balance FROM users WHERE phone = ? """, (query,)) else: # 이름 검색 cursor.execute(""" SELECT id, nickname, phone, mileage_balance FROM users WHERE nickname LIKE ? ORDER BY created_at DESC """, (f'%{query}%',)) results = cursor.fetchall() if not results: return jsonify({'success': False, 'message': '검색 결과가 없습니다'}), 404 if len(results) == 1: # 단일 매칭 - user_id만 반환 return jsonify({ 'success': True, 'multiple': False, 'user_id': results[0]['id'] }) else: # 여러 명 매칭 - 선택 모달용 데이터 반환 users = [{ 'id': row['id'], 'name': row['nickname'], 'phone': row['phone'], 'balance': row['mileage_balance'] } for row in results] return jsonify({ 'success': True, 'multiple': True, 'users': users }) except Exception as e: return jsonify({ 'success': False, 'message': f'검색 실패: {str(e)}' }), 500 @app.route('/admin/search/product') def admin_search_product(): """제품 검색 - 적립자 목록 반환 (SQLite 적립자 기준)""" query = request.args.get('q', '').strip() if not query: return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() try: # 1. MSSQL에서 제품명으로 거래번호 찾기 session = db_manager.get_session('PM_PRES') sale_items_query = text(""" SELECT DISTINCT S.SL_NO_order, S.SL_NM_item, M.InsertTime FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode LEFT JOIN SALE_MAIN M ON S.SL_NO_order = M.SL_NO_order WHERE G.GoodsName LIKE :product_name ORDER BY M.InsertTime DESC """) sale_results = session.execute(sale_items_query, { 'product_name': f'%{query}%' }).fetchall() if not sale_results: return jsonify({ 'success': True, 'results': [] }) # 2. SQLite에서 적립된 거래만 필터링 (claimed_by_user_id IS NOT NULL) transaction_ids = [row.SL_NO_order for row in sale_results] placeholders = ','.join('?' * len(transaction_ids)) cursor.execute(f""" SELECT ct.transaction_id, ct.total_amount, ct.claimed_at, ct.claimed_by_user_id, u.nickname, u.phone FROM claim_tokens ct JOIN users u ON ct.claimed_by_user_id = u.id WHERE ct.transaction_id IN ({placeholders}) AND ct.claimed_by_user_id IS NOT NULL ORDER BY ct.claimed_at DESC LIMIT 50 """, transaction_ids) claimed_results = cursor.fetchall() # 3. 결과 조합 results = [] for claim_row in claimed_results: # 해당 거래의 MSSQL 정보 찾기 mssql_row = next((r for r in sale_results if r.SL_NO_order == claim_row['transaction_id']), None) if mssql_row: results.append({ 'user_id': claim_row['claimed_by_user_id'], 'user_name': claim_row['nickname'], 'user_phone': claim_row['phone'], 'purchase_date': str(mssql_row.InsertTime)[:16].replace('T', ' ') if mssql_row.InsertTime else '-', # MSSQL 실제 거래 시간 'claimed_date': str(claim_row['claimed_at'])[:16].replace('T', ' ') if claim_row['claimed_at'] else '-', # 적립 시간 'quantity': float(mssql_row.SL_NM_item or 0), 'total_amount': int(claim_row['total_amount']) }) return jsonify({ 'success': True, 'results': results }) except Exception as e: return jsonify({ 'success': False, 'message': f'검색 실패: {str(e)}' }), 500 @app.route('/admin/use-points', methods=['POST']) def admin_use_points(): """관리자 페이지에서 포인트 사용 (차감)""" try: data = request.get_json() user_id = data.get('user_id') points_to_use = data.get('points') if not user_id or not points_to_use: return jsonify({ 'success': False, 'message': '사용자 ID와 포인트를 입력하세요.' }), 400 if points_to_use <= 0: return jsonify({ 'success': False, 'message': '1 이상의 포인트를 입력하세요.' }), 400 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 1. 현재 사용자 잔액 확인 cursor.execute(""" SELECT id, nickname, mileage_balance FROM users WHERE id = ? """, (user_id,)) user = cursor.fetchone() if not user: return jsonify({ 'success': False, 'message': '사용자를 찾을 수 없습니다.' }), 404 current_balance = user['mileage_balance'] # 2. 잔액 확인 if points_to_use > current_balance: return jsonify({ 'success': False, 'message': f'잔액({current_balance:,}P)이 부족합니다.' }), 400 # 3. 포인트 차감 new_balance = current_balance - points_to_use cursor.execute(""" UPDATE users SET mileage_balance = ? WHERE id = ? """, (new_balance, user_id)) # 4. 마일리지 원장 기록 cursor.execute(""" INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason, description) VALUES (?, NULL, ?, ?, 'USE', ?) """, ( user_id, -points_to_use, # 음수로 저장 new_balance, f'포인트 사용 (관리자) - {points_to_use:,}P 차감' )) conn.commit() logging.info(f"포인트 사용 완료: 사용자 ID {user_id}, 차감 {points_to_use}P, 잔액 {new_balance}P") return jsonify({ 'success': True, 'message': f'{points_to_use:,}P 사용 완료', 'new_balance': new_balance, 'used_points': points_to_use }) except Exception as e: logging.error(f"포인트 사용 실패: {e}") return jsonify({ 'success': False, 'message': f'포인트 사용 실패: {str(e)}' }), 500 @app.route('/admin') def admin(): """관리자 페이지 - 전체 사용자 및 적립 현황""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 전체 통계 cursor.execute(""" SELECT COUNT(*) as total_users, SUM(mileage_balance) as total_balance FROM users """) stats = cursor.fetchone() # 최근 가입 사용자 (20명) cursor.execute(""" SELECT id, nickname, phone, mileage_balance, created_at FROM users ORDER BY created_at DESC LIMIT 20 """) recent_users_raw = cursor.fetchall() # 시간을 KST로 변환 recent_users = [] for user in recent_users_raw: user_dict = dict(user) user_dict['created_at'] = utc_to_kst_str(user['created_at']) recent_users.append(user_dict) # 최근 적립 내역 (50건) cursor.execute(""" SELECT ml.id, u.nickname, u.phone, ml.points, ml.balance_after, ml.reason, ml.description, ml.created_at FROM mileage_ledger ml JOIN users u ON ml.user_id = u.id ORDER BY ml.created_at DESC LIMIT 50 """) recent_transactions_raw = cursor.fetchall() # 시간을 KST로 변환 recent_transactions = [] for trans in recent_transactions_raw: trans_dict = dict(trans) trans_dict['created_at'] = utc_to_kst_str(trans['created_at']) recent_transactions.append(trans_dict) # QR 토큰 통계 cursor.execute(""" SELECT COUNT(*) as total_tokens, SUM(CASE WHEN claimed_at IS NOT NULL THEN 1 ELSE 0 END) as claimed_count, SUM(CASE WHEN claimed_at IS NULL THEN 1 ELSE 0 END) as unclaimed_count, SUM(claimable_points) as total_points_issued, SUM(CASE WHEN claimed_at IS NOT NULL THEN claimable_points ELSE 0 END) as total_points_claimed FROM claim_tokens """) token_stats = cursor.fetchone() # 최근 QR 발행 내역 (20건) cursor.execute(""" SELECT transaction_id, total_amount, claimable_points, claimed_at, claimed_by_user_id, created_at FROM claim_tokens ORDER BY created_at DESC LIMIT 20 """) recent_tokens_raw = cursor.fetchall() # Convert only created_at (발행일), leave claimed_at (적립일) unconverted recent_tokens = [] for token in recent_tokens_raw: token_dict = dict(token) token_dict['created_at'] = utc_to_kst_str(token['created_at']) # Convert 발행일 # claimed_at stays as-is (or remains None if not claimed) recent_tokens.append(token_dict) return render_template('admin.html', stats=stats, recent_users=recent_users, recent_transactions=recent_transactions, token_stats=token_stats, recent_tokens=recent_tokens) if __name__ == '__main__': # 개발 모드로 실행 app.run(host='0.0.0.0', port=7001, debug=True)