Files
ai-customer-service/guest-cs-stack/flask/repository.py
T
2026-08-15 14:51:51 +08:00

532 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Optional
from config import CONFIG
from db import db_connection
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_]+$")
_FROZEN_MARK_RE = re.compile(r"[\((]?\s*已冻结\s*[\))]?")
class MappingError(RuntimeError):
pass
def safe_identifier(value: str) -> str:
if not value or not _IDENTIFIER_RE.fullmatch(value):
raise MappingError(f"不安全或無效的資料庫識別字:{value!r}")
return f"`{value}`"
def table_name(value: str) -> str:
return safe_identifier(value)
def decimal_string(value: Any, default: str = "0.0000") -> str:
if value is None or value == "":
return default
try:
return format(Decimal(str(value)), "f")
except Exception:
return str(value)
def format_datetime(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value)
def clean_frozen_username(value: str) -> str:
cleaned = _FROZEN_MARK_RE.sub("", str(value or ""))
return cleaned.strip()
def is_finished_status(value: Any) -> bool:
status = str(value or "").strip().lower()
if not status:
return False
final_keywords = (
"success", "successful", "completed", "complete", "approved",
"paid", "done", "rejected", "cancelled", "canceled", "failed",
"成功", "完成", "已支付", "已出款", "出款成功", "审核通过",
"審核通過", "通过", "通過", "拒绝", "拒絕", "驳回", "駁回",
"取消", "失败", "失敗",
)
return any(keyword in status for keyword in final_keywords)
class PlatformRepository:
"""依照現有 member_system 真實表結構建立的唯讀資料層。"""
def ensure_simulation_tables(self) -> None:
"""唯讀測試版不建立資料表,也不對採集庫執行寫入。"""
return
def get_user_account(self, username: str) -> dict[str, Any]:
"""查詢 members,並由 username 是否包含「已冻结」判斷鎖定。"""
login_username = str(username or "").strip()
if not login_username:
return self._not_found_account("")
members = table_name(CONFIG.members_table)
sql = f"""
SELECT
id,
uid,
username,
username_clean,
member_type,
vip_level,
points,
tier,
balance,
frozen_balance,
other_balance,
deposit_count,
deposit_amount,
withdraw_count,
withdraw_amount,
register_time,
last_login_time,
remark,
updated_at
FROM {members}
WHERE username = %s OR username_clean = %s
ORDER BY
CASE
WHEN username = %s THEN 0
WHEN username_clean = %s THEN 1
ELSE 2
END,
updated_at DESC,
id DESC
LIMIT 1
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(login_username, login_username, login_username, login_username),
)
row = cursor.fetchone()
# 少數舊資料可能未回填 username_clean;再用移除凍結標記後比對。
if not row:
fallback_sql = f"""
SELECT
id,
uid,
username,
username_clean,
member_type,
vip_level,
points,
tier,
balance,
frozen_balance,
other_balance,
deposit_count,
deposit_amount,
withdraw_count,
withdraw_amount,
register_time,
last_login_time,
remark,
updated_at
FROM {members}
WHERE TRIM(
REPLACE(
REPLACE(
REPLACE(username, '(已冻结)', ''),
'(已冻结)', ''
),
'已冻结', ''
)
) = %s
ORDER BY updated_at DESC, id DESC
LIMIT 1
"""
cursor.execute(fallback_sql, (login_username,))
row = cursor.fetchone()
if not row:
return self._not_found_account(login_username)
raw_username = str(row.get("username") or login_username).strip()
clean_username = str(row.get("username_clean") or "").strip()
if not clean_username:
clean_username = clean_frozen_username(raw_username)
if not clean_username:
clean_username = login_username
locked = "已冻结" in raw_username
remark = str(row.get("remark") or "").strip()
return {
"username": clean_username,
"raw_username": raw_username,
"username_clean": clean_username,
"uid": row.get("uid"),
"is_exist": True,
"status": "locked" if locked else "normal",
"locked_reason": remark if locked else "",
"remark": remark,
"member_type": str(row.get("member_type") or ""),
"vip_level": str(row.get("vip_level") or ""),
"points": decimal_string(row.get("points")),
"tier": str(row.get("tier") or ""),
"balance": decimal_string(row.get("balance")),
"frozen_balance": decimal_string(row.get("frozen_balance")),
"other_balance": decimal_string(row.get("other_balance")),
"deposit_count": int(row.get("deposit_count") or 0),
"deposit_amount": decimal_string(row.get("deposit_amount")),
"withdraw_count": int(row.get("withdraw_count") or 0),
"withdraw_amount": decimal_string(row.get("withdraw_amount")),
"register_time": format_datetime(row.get("register_time")),
"last_login_time": format_datetime(row.get("last_login_time")),
}
@staticmethod
def _not_found_account(username: str) -> dict[str, Any]:
return {
"username": username,
"raw_username": "",
"username_clean": username,
"uid": None,
"is_exist": False,
"status": "not_found",
"locked_reason": "",
}
def get_balance(self, username: str) -> Decimal:
account = self.get_user_account(username)
if not account["is_exist"]:
raise MappingError(f"members 表找不到會員:{username}")
return Decimal(str(account.get("balance") or "0"))
def get_withdraw_orders(
self,
username: str,
unfinished_only: bool = False,
) -> list[dict[str, Any]]:
account = self.get_user_account(username)
clean_username = (
account["username_clean"] if account["is_exist"] else str(username).strip()
)
raw_username = (
account["raw_username"] if account["is_exist"] else str(username).strip()
)
records = self._query_withdraw_records(clean_username, raw_username)
requests = self._query_withdraw_requests(clean_username, raw_username)
combined = records + requests
if unfinished_only:
combined = [
item for item in combined
if not is_finished_status(item.get("status"))
]
combined.sort(
key=lambda item: item.get("sort_time") or datetime.min,
reverse=True,
)
for item in combined:
item.pop("sort_time", None)
return combined[: CONFIG.max_orders]
def _query_withdraw_records(
self,
clean_username: str,
raw_username: str,
) -> list[dict[str, Any]]:
table = table_name(CONFIG.withdraw_table)
sql = f"""
SELECT
withdraw_id,
withdraw_id_2,
username,
username_clean,
withdraw_amount,
fee,
arrival_amount,
withdraw_channel,
withdraw_time,
status,
remark,
reviewer,
review_time,
processor,
process_time
FROM {table}
WHERE
username_clean = %s
OR username = %s
OR username = %s
ORDER BY withdraw_time DESC, id DESC
LIMIT %s
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(clean_username, clean_username, raw_username, CONFIG.max_orders),
)
rows = cursor.fetchall()
return [
{
"source": "withdraw_records",
"order_id": str(
row.get("withdraw_id") or row.get("withdraw_id_2") or ""
),
"username": clean_username,
"status": str(row.get("status") or ""),
"amount": float(row.get("withdraw_amount") or 0),
"fee": float(row.get("fee") or 0),
"arrival_amount": float(row.get("arrival_amount") or 0),
"withdraw_channel": str(row.get("withdraw_channel") or ""),
"create_time": format_datetime(row.get("withdraw_time")),
"fail_reason": str(row.get("remark") or ""),
"reviewer": str(row.get("reviewer") or ""),
"review_time": format_datetime(row.get("review_time")),
"processor": str(row.get("processor") or ""),
"process_time": format_datetime(row.get("process_time")),
"sort_time": row.get("withdraw_time") or datetime.min,
}
for row in rows
]
def _query_withdraw_requests(
self,
clean_username: str,
raw_username: str,
) -> list[dict[str, Any]]:
"""查詢提款申請。
狀態判斷口徑:
1. operation 為主要狀態欄位;
2. operation 為空時才使用 audit_status;
3. 同一 withdraw_request_id 只保留最新一筆;
4. withdraw_request_id 為空時保留該筆資料,不做錯誤合併。
"""
table = table_name(CONFIG.withdraw_request_table)
# 多抓一些歷史快照,再於 Python 端依 withdraw_request_id 去重。
# SQL 已按 request_time、id 倒序,所以第一筆就是最新狀態。
fetch_limit = max(CONFIG.max_orders * 5, CONFIG.max_orders)
sql = f"""
SELECT
id,
withdraw_request_id,
username_raw,
username_clean,
current_balance,
withdraw_channel,
withdraw_amount,
request_time,
turnover,
operation,
auto_withdraw,
audit_status,
remark
FROM {table}
WHERE
username_clean = %s
OR username_raw = %s
OR username_raw = %s
OR username_raw LIKE %s
OR username_raw LIKE %s
ORDER BY request_time DESC, id DESC
LIMIT %s
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(
clean_username,
clean_username,
raw_username,
f"{clean_username}(%",
f"{clean_username}(%",
fetch_limit,
),
)
rows = cursor.fetchall()
latest_rows: list[dict[str, Any]] = []
seen_request_ids: set[str] = set()
for row in rows:
request_id = row.get("withdraw_request_id")
# 有 request_id:只保留倒序結果中的第一筆,也就是最新狀態。
if request_id not in (None, ""):
request_key = str(request_id)
if request_key in seen_request_ids:
continue
seen_request_ids.add(request_key)
# 沒有 request_id:無法可靠判定是否同一筆申請,因此保留。
latest_rows.append(row)
if len(latest_rows) >= CONFIG.max_orders:
break
result: list[dict[str, Any]] = []
for row in latest_rows:
operation = str(row.get("operation") or "").strip()
audit_status = str(row.get("audit_status") or "").strip()
# 提款申請狀態以 operation 為主,audit_status 僅作備援。
status = operation or audit_status or "UNKNOWN"
result.append(
{
"source": "withdraw_request_records",
"order_id": str(row.get("withdraw_request_id") or ""),
"record_id": int(row.get("id") or 0),
"username": clean_username,
"status": status,
"operation": operation,
"audit_status": audit_status,
"amount": float(row.get("withdraw_amount") or 0),
"fee": 0.0,
"arrival_amount": 0.0,
"current_balance": float(row.get("current_balance") or 0),
"turnover": float(row.get("turnover") or 0),
"withdraw_channel": str(row.get("withdraw_channel") or ""),
"create_time": format_datetime(row.get("request_time")),
"fail_reason": str(row.get("remark") or ""),
"auto_withdraw": str(row.get("auto_withdraw") or ""),
"sort_time": row.get("request_time") or datetime.min,
}
)
return result
def get_deposit_orders(
self,
username: str,
amount: Optional[Decimal] = None,
created_at: Optional[datetime] = None,
) -> list[dict[str, Any]]:
account = self.get_user_account(username)
clean_username = (
account["username_clean"] if account["is_exist"] else str(username).strip()
)
raw_username = (
account["raw_username"] if account["is_exist"] else str(username).strip()
)
table = table_name(CONFIG.deposit_table)
conditions = [
"(username_clean = %s OR username = %s OR username = %s)"
]
params: list[Any] = [clean_username, clean_username, raw_username]
if amount is not None:
conditions.append("ABS(deposit_amount - %s) < 0.01")
params.append(amount)
if created_at is not None:
hours = CONFIG.deposit_search_hours
conditions.append("deposit_time BETWEEN %s AND %s")
params.extend(
[
created_at - timedelta(hours=hours),
created_at + timedelta(hours=hours),
]
)
sql = f"""
SELECT
deposit_id,
deposit_no,
username,
username_clean,
deposit_amount,
actual_arrival_amount,
crypto_amount,
fee,
deposit_bank,
deposit_type,
status,
description,
deposit_time,
arrival_time,
operation_time,
backend_remark,
frontend_remark
FROM {table}
WHERE {' AND '.join(conditions)}
ORDER BY deposit_time DESC, id DESC
LIMIT %s
"""
params.append(CONFIG.max_orders)
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return [
{
"username": clean_username,
"thirdparty_order_id": str(
row.get("deposit_no") or row.get("deposit_id") or ""
),
"deposit_id": str(row.get("deposit_id") or ""),
"deposit_no": str(row.get("deposit_no") or ""),
"amount": float(row.get("deposit_amount") or 0),
"actual_arrival_amount": float(
row.get("actual_arrival_amount") or 0
),
"crypto_amount": float(row.get("crypto_amount") or 0),
"fee": float(row.get("fee") or 0),
"status": str(row.get("status") or ""),
"deposit_bank": str(row.get("deposit_bank") or ""),
"deposit_type": str(row.get("deposit_type") or ""),
"description": str(row.get("description") or ""),
"created_at": format_datetime(row.get("deposit_time")),
"arrival_time": format_datetime(row.get("arrival_time")),
"operation_time": format_datetime(row.get("operation_time")),
"backend_remark": str(row.get("backend_remark") or ""),
"frontend_remark": str(row.get("frontend_remark") or ""),
}
for row in rows
]
def apply_activity(self, username: str, activity_id: str) -> dict[str, Any]:
"""唯讀測試:只模擬資格回傳,不寫入資料庫。"""
account = self.get_user_account(username)
if not account["is_exist"]:
return {
"activity_found": False,
"qualified": False,
"status": "user_not_found",
"simulated": True,
}
activity_found = activity_id == CONFIG.default_activity_id
return {
"activity_found": activity_found,
"qualified": activity_found,
"status": (
"simulated_qualified" if activity_found else "activity_not_found"
),
"simulated": True,
}
def log_balance_fix(self, username: str, balance: Decimal) -> None:
"""唯讀模式不寫入任何修復日誌。"""
return