支付功能
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
I will optimize the registration and payment flow with the specific requirement to record the actual payment amount.
|
||||
|
||||
### 1. Backend Updates (`main.py`)
|
||||
- **Database Schema**: Add `out_trade_no` column to `gsdh_data` to link users with payment orders.
|
||||
- **Pre-payment Validation**:
|
||||
- Update `create_payment` (H5) and `create_native_payment`.
|
||||
- Check if phone exists in `gsdh_data`.
|
||||
- **Logic**:
|
||||
- If `fee` has a valid amount (not empty, '0', or 'PENDING'), reject as "Already Registered".
|
||||
- If `fee` is empty/PENDING, allow payment.
|
||||
- Upsert user with `fee` = 'PENDING' and `payment_channel` = '..._pending'.
|
||||
- **Payment Status Sync**:
|
||||
- Update `check_payment_status`:
|
||||
- When WeChat returns `SUCCESS`:
|
||||
- **Update `fee`** to the actual configured payment amount (e.g., "0.01").
|
||||
- Update `payment_channel` to the final channel (removing '_pending').
|
||||
- Update `out_trade_no` logic to ensure we find the correct user.
|
||||
|
||||
### 2. Frontend Optimization (`templates/ticket.html`)
|
||||
- **Dynamic Form Fields**:
|
||||
- Replace hardcoded inputs with a loop that renders fields based on `config.field_config` (managed in Admin).
|
||||
- Respect "Show", "Required", and "Label" settings.
|
||||
- **QR Code Modal**:
|
||||
- Add explicit text: "请使用微信扫一扫完成支付" (Please use WeChat Scan to pay).
|
||||
- Optimize layout.
|
||||
- **Payment Logic**:
|
||||
- Handle "Already Registered" error.
|
||||
- Redirect to Success Page on completion.
|
||||
|
||||
### 3. New Success Page
|
||||
- Create `templates/success.html` to show a "Registration Successful" message.
|
||||
- Add route in `main.py`.
|
||||
|
||||
### 4. Admin Interface
|
||||
- Verify `templates/admin.html` field configuration works with the new `ticket.html` dynamic rendering.
|
||||
|
||||
This ensures that successful payments overwrite the 'PENDING' status with the actual amount paid.
|
||||
Binary file not shown.
53
config.json
53
config.json
@@ -9,20 +9,37 @@
|
||||
"bg_color": "#050814",
|
||||
"header_image": "/static/image1.jpg",
|
||||
"enable_ticket_validation": false,
|
||||
"enable_payment": false,
|
||||
"enable_payment": true,
|
||||
"payment_amount": 0.01,
|
||||
"wechat_pay_config": {
|
||||
"appid": "",
|
||||
"mchid": "",
|
||||
"api_v3_key": "",
|
||||
"serial_no": "",
|
||||
"private_key_path": "",
|
||||
"notify_url": ""
|
||||
"appid": "wxdf2ca73e6c0929f0",
|
||||
"mchid": "1723685511",
|
||||
"api_v3_key": "XishanquBanzhanweixinzhifumiyao3",
|
||||
"serial_no": "49E299FC2F01841ECD6658D8B037C5338BD170BA",
|
||||
"private_key_path": "static/cert/apiclient_key.pem",
|
||||
"notify_url": "https://event.quant-speed.com/ticket/finish/"
|
||||
},
|
||||
"enable_seating": false,
|
||||
"total_tables": 14,
|
||||
"max_per_table": 10,
|
||||
"field_config": {
|
||||
"ticket_field_config": {
|
||||
"name": {
|
||||
"label": "姓名",
|
||||
"show": true,
|
||||
"required": true
|
||||
},
|
||||
"phone": {
|
||||
"label": "手机号码",
|
||||
"show": true,
|
||||
"required": true
|
||||
},
|
||||
"industry_company": {
|
||||
"label": "单位名称",
|
||||
"show": true,
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"checkin_field_config": {
|
||||
"name": {
|
||||
"label": "姓名",
|
||||
"show": true,
|
||||
@@ -36,31 +53,31 @@
|
||||
"company_name": {
|
||||
"label": "单位名称",
|
||||
"show": true,
|
||||
"required": true
|
||||
"required": false
|
||||
},
|
||||
"position": {
|
||||
"label": "职务",
|
||||
"show": true,
|
||||
"required": true
|
||||
"required": false
|
||||
},
|
||||
"business_scope": {
|
||||
"label": "公司主要经营 / 业务",
|
||||
"show": true,
|
||||
"required": true
|
||||
"required": false
|
||||
},
|
||||
"vision_2026": {
|
||||
"label": "2026年业务愿景",
|
||||
"show": false,
|
||||
"show": true,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"wall_config": {
|
||||
"bg_opacity": 0.9,
|
||||
"bg_opacity": 0.3,
|
||||
"show_title": true,
|
||||
"learn_more_url": "https://www.example.com",
|
||||
"learn_more_url": "",
|
||||
"show_fields": {
|
||||
"name": true,
|
||||
"company_name": false,
|
||||
"company_name": true,
|
||||
"position": true,
|
||||
"vision_2026": true,
|
||||
"business_scope": true
|
||||
@@ -68,7 +85,7 @@
|
||||
},
|
||||
"db_host": "6.6.6.66",
|
||||
"db_port": "5432",
|
||||
"db_user": "AI_event",
|
||||
"db_password": "123AI_event",
|
||||
"db_name": "AI_event"
|
||||
"db_user": "123",
|
||||
"db_password": "1231",
|
||||
"db_name": "123"
|
||||
}
|
||||
@@ -19,10 +19,16 @@
|
||||
# SERVER_PORT="6222"
|
||||
|
||||
# 配置局域网变量 - 公司局域网上传方法
|
||||
SERVER_HOST="6.6.6.86" # 服务器IP地址
|
||||
SERVER_USER="ubuntu" # 服务器用户名
|
||||
SERVER_PASSWORD="qweasdzxc1" # 服务器密码
|
||||
SERVER_PORT="22" # SSH端口,默认22
|
||||
# SERVER_HOST="6.6.6.86" # 服务器IP地址
|
||||
# SERVER_USER="ubuntu" # 服务器用户名
|
||||
# SERVER_PASSWORD="qweasdzxc1" # 服务器密码
|
||||
# SERVER_PORT="22" # SSH端口,默认22
|
||||
|
||||
|
||||
SERVER_HOST="6.6.6.66" # 服务器IP地址
|
||||
SERVER_USER="quant" # 服务器用户名
|
||||
SERVER_PASSWORD="123quant-speed" # 服务器密码
|
||||
SERVER_PORT="22"
|
||||
|
||||
|
||||
IMAGE_NAME="checkin_sys" # Docker镜像名称
|
||||
@@ -163,27 +169,32 @@ deploy_on_server() {
|
||||
sshpass -p "$SERVER_PASSWORD" ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no "${SERVER_USER}@${SERVER_HOST}" << EOF
|
||||
set -e
|
||||
|
||||
# 定义 sudo 包装函数以处理密码输入
|
||||
run_sudo() {
|
||||
echo "$SERVER_PASSWORD" | sudo -S -p '' "\$@"
|
||||
}
|
||||
|
||||
echo "[INFO] 开始服务器端部署..."
|
||||
|
||||
# 检查并停止现有容器
|
||||
if sudo docker ps -a --format 'table {{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if run_sudo docker ps -a --format 'table {{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "[INFO] 发现现有容器 ${CONTAINER_NAME},正在停止并删除..."
|
||||
sudo docker stop ${CONTAINER_NAME} || true
|
||||
sudo docker rm ${CONTAINER_NAME} || true
|
||||
run_sudo docker stop ${CONTAINER_NAME} || true
|
||||
run_sudo docker rm ${CONTAINER_NAME} || true
|
||||
fi
|
||||
|
||||
# 检查并删除现有镜像
|
||||
if sudo docker images --format 'table {{.Repository}}:{{.Tag}}' | grep -q "^${IMAGE_NAME}:${IMAGE_TAG}$"; then
|
||||
if run_sudo docker images --format 'table {{.Repository}}:{{.Tag}}' | grep -q "^${IMAGE_NAME}:${IMAGE_TAG}$"; then
|
||||
echo "[INFO] 发现现有镜像 ${IMAGE_NAME}:${IMAGE_TAG},正在删除..."
|
||||
sudo docker rmi ${IMAGE_NAME}:${IMAGE_TAG} || true
|
||||
run_sudo docker rmi ${IMAGE_NAME}:${IMAGE_TAG} || true
|
||||
fi
|
||||
|
||||
# 加载新镜像
|
||||
echo "[INFO] 加载新镜像..."
|
||||
sudo docker load -i /tmp/${TAR_FILE}
|
||||
run_sudo docker load -i /tmp/${TAR_FILE}
|
||||
|
||||
# 验证镜像是否加载成功
|
||||
if sudo docker images | grep -q "${IMAGE_NAME}"; then
|
||||
if run_sudo docker images | grep -q "${IMAGE_NAME}"; then
|
||||
echo "[SUCCESS] 镜像加载成功"
|
||||
else
|
||||
echo "[ERROR] 镜像加载失败"
|
||||
@@ -194,21 +205,21 @@ deploy_on_server() {
|
||||
echo "[INFO] 启动新容器..."
|
||||
# 使用 host 网络模式,让容器直接使用宿主机网络栈,从而可以通过 localhost:5432 访问宿主机数据库
|
||||
# 同时覆盖环境变量,强制使用本地数据库配置
|
||||
sudo docker run -d --network host --name ${CONTAINER_NAME} \
|
||||
run_sudo docker run -d --network host --name ${CONTAINER_NAME} \
|
||||
--env-file /tmp/.env \
|
||||
-e DB_HOST=localhost \
|
||||
-e DB_PORT=5432 \
|
||||
${IMAGE_NAME}:${IMAGE_TAG}
|
||||
|
||||
# 验证容器是否启动成功
|
||||
if sudo docker ps | grep -q "${CONTAINER_NAME}"; then
|
||||
if run_sudo docker ps | grep -q "${CONTAINER_NAME}"; then
|
||||
echo "[SUCCESS] 容器启动成功"
|
||||
echo "[INFO] 容器状态:"
|
||||
sudo docker ps | grep "${CONTAINER_NAME}"
|
||||
run_sudo docker ps | grep "${CONTAINER_NAME}"
|
||||
else
|
||||
echo "[ERROR] 容器启动失败"
|
||||
echo "[INFO] 查看容器日志:"
|
||||
sudo docker logs ${CONTAINER_NAME}
|
||||
run_sudo docker logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
230
main.py
230
main.py
@@ -13,6 +13,11 @@ import os
|
||||
import json
|
||||
import shutil
|
||||
import difflib
|
||||
import time
|
||||
import base64
|
||||
import requests
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -39,7 +44,12 @@ DEFAULT_CONFIG = {
|
||||
"enable_seating": True,
|
||||
"total_tables": 14,
|
||||
"max_per_table": 10,
|
||||
"field_config": {
|
||||
"ticket_field_config": {
|
||||
"name": {"label": "姓名", "show": True, "required": True},
|
||||
"phone": {"label": "手机号码", "show": True, "required": True},
|
||||
"industry_company": {"label": "单位名称", "show": True, "required": True}
|
||||
},
|
||||
"checkin_field_config": {
|
||||
"name": {"label": "姓名", "show": True, "required": True},
|
||||
"phone": {"label": "手机号码", "show": True, "required": True},
|
||||
"company_name": {"label": "单位名称", "show": True, "required": False},
|
||||
@@ -1198,6 +1208,7 @@ def reset_database():
|
||||
industry_company VARCHAR(200),
|
||||
fee VARCHAR(50),
|
||||
payment_channel VARCHAR(50),
|
||||
out_trade_no VARCHAR(100),
|
||||
is_signed VARCHAR(10) DEFAULT 'FALSE'
|
||||
);
|
||||
""")
|
||||
@@ -1270,6 +1281,7 @@ def init_database():
|
||||
industry_company VARCHAR(200),
|
||||
fee VARCHAR(50),
|
||||
payment_channel VARCHAR(50),
|
||||
out_trade_no VARCHAR(100),
|
||||
is_signed VARCHAR(10) DEFAULT 'FALSE'
|
||||
);
|
||||
""")
|
||||
@@ -1323,6 +1335,7 @@ def init_database():
|
||||
|
||||
safe_add_column('gsdh_data', 'fee', 'VARCHAR(50)')
|
||||
safe_add_column('gsdh_data', 'payment_channel', 'VARCHAR(50)')
|
||||
safe_add_column('gsdh_data', 'out_trade_no', 'VARCHAR(100)')
|
||||
safe_add_column('gsdh_data', 'is_signed', "VARCHAR(10) DEFAULT 'FALSE'")
|
||||
|
||||
safe_add_column('checkin_info', 'social_point', 'INTEGER DEFAULT 5')
|
||||
@@ -1434,6 +1447,58 @@ class WeChatPayService:
|
||||
else:
|
||||
raise Exception(f"WeChat Pay Error: {response.text}")
|
||||
|
||||
def native_payment(self, description, out_trade_no, amount_fen, client_ip=None):
|
||||
url = "https://api.mch.weixin.qq.com/v3/pay/transactions/native"
|
||||
path = "/v3/pay/transactions/native"
|
||||
|
||||
data = {
|
||||
"appid": self.appid,
|
||||
"mchid": self.mchid,
|
||||
"description": description,
|
||||
"out_trade_no": out_trade_no,
|
||||
"notify_url": self.notify_url,
|
||||
"amount": {
|
||||
"total": amount_fen,
|
||||
"currency": "CNY"
|
||||
}
|
||||
}
|
||||
|
||||
body = json.dumps(data)
|
||||
auth = self.build_authorization_header("POST", path, body)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": auth
|
||||
}
|
||||
|
||||
response = requests.post(url, data=body, headers=headers)
|
||||
|
||||
if response.status_code in [200, 202]:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"WeChat Pay Error: {response.text}")
|
||||
|
||||
def query_order(self, out_trade_no):
|
||||
url = f"https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mchid}"
|
||||
path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mchid}"
|
||||
|
||||
auth = self.build_authorization_header("GET", path, "")
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": auth
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 404:
|
||||
return {"trade_state": "NOTPAY"} # Or handle as not found
|
||||
else:
|
||||
raise Exception(f"WeChat Query Error: {response.text}")
|
||||
|
||||
@app.get("/ticket", response_class=HTMLResponse)
|
||||
async def ticket_page(request: Request):
|
||||
global CONFIG
|
||||
@@ -1444,6 +1509,12 @@ async def ticket_page(request: Request):
|
||||
|
||||
return templates.TemplateResponse("ticket.html", {"request": request, "config": CONFIG})
|
||||
|
||||
@app.get("/success", response_class=HTMLResponse)
|
||||
async def success_page(request: Request):
|
||||
global CONFIG
|
||||
CONFIG = load_config()
|
||||
return templates.TemplateResponse("success.html", {"request": request, "config": CONFIG})
|
||||
|
||||
class TicketPaymentRequest(BaseModel):
|
||||
name: str
|
||||
phone: str
|
||||
@@ -1488,23 +1559,31 @@ async def create_payment(req: TicketPaymentRequest, request: Request):
|
||||
new_id = str(max_id + 1)
|
||||
|
||||
# Upsert user (if phone exists, update; else insert)
|
||||
# Actually, if phone exists, we should update.
|
||||
cur.execute("SELECT new_id FROM gsdh_data WHERE phone = %s", (req.phone,))
|
||||
cur.execute("SELECT new_id, fee FROM gsdh_data WHERE phone = %s", (req.phone,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
user_id = existing[0]
|
||||
current_fee = existing[1]
|
||||
|
||||
# Validation: Check if already paid
|
||||
# Conditions for "Unpaid": None, Empty String, '0', 'PENDING'
|
||||
if current_fee and current_fee not in ['0', 'PENDING']:
|
||||
cur.close()
|
||||
release_db_connection(conn)
|
||||
return JSONResponse(content={"success": False, "message": "该手机号已完成报名,无需重复支付"}, status_code=400)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE gsdh_data
|
||||
SET name=%s, industry_company=%s, fee='PENDING', payment_channel='wechat_h5_pending'
|
||||
SET name=%s, industry_company=%s, fee='PENDING', payment_channel='wechat_h5_pending', out_trade_no=%s
|
||||
WHERE new_id=%s
|
||||
""", (req.name, req.company_name, user_id))
|
||||
""", (req.name, req.company_name, out_trade_no, user_id))
|
||||
else:
|
||||
user_id = new_id
|
||||
cur.execute("""
|
||||
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed)
|
||||
VALUES (%s, %s, %s, %s, 'PENDING', 'wechat_h5_pending', 'FALSE')
|
||||
""", (user_id, req.name, req.phone, req.company_name))
|
||||
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed, out_trade_no)
|
||||
VALUES (%s, %s, %s, %s, 'PENDING', 'wechat_h5_pending', 'FALSE', %s)
|
||||
""", (user_id, req.name, req.phone, req.company_name, out_trade_no))
|
||||
|
||||
# Also store extra info in checkin_info?
|
||||
# Requirement: "If registration paid then add to gsdh_data"
|
||||
@@ -1540,6 +1619,141 @@ async def create_payment(req: TicketPaymentRequest, request: Request):
|
||||
release_db_connection(conn)
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
@app.post("/api/payment/native")
|
||||
async def create_native_payment(req: TicketPaymentRequest, request: Request):
|
||||
"""
|
||||
处理 Native 支付请求 (扫码支付)。
|
||||
"""
|
||||
try:
|
||||
global CONFIG
|
||||
CONFIG = load_config()
|
||||
|
||||
if not CONFIG.get("enable_payment", False):
|
||||
return JSONResponse(content={"success": False, "message": "支付未开启"}, status_code=403)
|
||||
|
||||
wc_config = CONFIG.get("wechat_pay_config", {})
|
||||
service = WeChatPayService(wc_config)
|
||||
|
||||
# 1. Generate Order ID
|
||||
out_trade_no = f"TICKET_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
# 2. Amount (Convert to Fen)
|
||||
amount_yuan = float(CONFIG.get("payment_amount", 0.01))
|
||||
amount_fen = int(amount_yuan * 100)
|
||||
|
||||
# 3. Save Temporary User Data (Pending Payment)
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Calculate new ID
|
||||
cur.execute("SELECT MAX(CAST(new_id AS INTEGER)) FROM gsdh_data WHERE new_id ~ '^[0-9]+$'")
|
||||
row = cur.fetchone()
|
||||
max_id = row[0] if row and row[0] is not None else 0
|
||||
new_id = str(max_id + 1)
|
||||
|
||||
# Upsert user
|
||||
cur.execute("SELECT new_id, fee FROM gsdh_data WHERE phone = %s", (req.phone,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
user_id = existing[0]
|
||||
current_fee = existing[1]
|
||||
|
||||
# Validation: Check if already paid
|
||||
if current_fee and current_fee not in ['0', 'PENDING']:
|
||||
cur.close()
|
||||
release_db_connection(conn)
|
||||
return JSONResponse(content={"success": False, "message": "该手机号已完成报名,无需重复支付"}, status_code=400)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE gsdh_data
|
||||
SET name=%s, industry_company=%s, fee='PENDING', payment_channel='微信线上支付_pending', out_trade_no=%s
|
||||
WHERE new_id=%s
|
||||
""", (req.name, req.company_name, out_trade_no, user_id))
|
||||
else:
|
||||
user_id = new_id
|
||||
cur.execute("""
|
||||
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed, out_trade_no)
|
||||
VALUES (%s, %s, %s, %s, 'PENDING', '微信线上支付_pending', 'FALSE', %s)
|
||||
""", (user_id, req.name, req.phone, req.company_name, out_trade_no))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
release_db_connection(conn)
|
||||
|
||||
# 4. Call WeChat Pay
|
||||
try:
|
||||
res = service.native_payment(
|
||||
description=f"{CONFIG.get('event_title', 'Event')} Ticket",
|
||||
out_trade_no=out_trade_no,
|
||||
amount_fen=amount_fen,
|
||||
client_ip=request.client.host
|
||||
)
|
||||
code_url = res.get("code_url")
|
||||
return {"success": True, "code_url": code_url, "out_trade_no": out_trade_no}
|
||||
|
||||
except Exception as wx_e:
|
||||
print(f"WeChat Pay Failed: {wx_e}")
|
||||
if "Private key not loaded" in str(wx_e):
|
||||
return JSONResponse(content={"success": False, "message": "服务端未配置支付证书,无法发起支付"}, status_code=500)
|
||||
return JSONResponse(content={"success": False, "message": f"支付请求失败: {str(wx_e)}"}, status_code=500)
|
||||
|
||||
except Exception as e:
|
||||
if 'conn' in locals() and conn:
|
||||
release_db_connection(conn)
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
@app.get("/api/payment/check/{out_trade_no}")
|
||||
async def check_payment_status(out_trade_no: str):
|
||||
"""
|
||||
查询订单支付状态。
|
||||
"""
|
||||
try:
|
||||
global CONFIG
|
||||
CONFIG = load_config()
|
||||
wc_config = CONFIG.get("wechat_pay_config", {})
|
||||
service = WeChatPayService(wc_config)
|
||||
|
||||
# Call WeChat Query API
|
||||
try:
|
||||
res = service.query_order(out_trade_no)
|
||||
trade_state = res.get("trade_state")
|
||||
|
||||
if trade_state == "SUCCESS":
|
||||
# Update DB to mark as paid
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get actual amount from Config
|
||||
amount = str(CONFIG.get("payment_amount", 0.01))
|
||||
|
||||
# Update gsdh_data
|
||||
# Remove '_pending' suffix from payment_channel and set actual fee
|
||||
cur.execute("""
|
||||
UPDATE gsdh_data
|
||||
SET fee = %s,
|
||||
payment_channel = REPLACE(payment_channel, '_pending', '')
|
||||
WHERE out_trade_no = %s
|
||||
""", (amount, out_trade_no))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
|
||||
return {"success": True, "trade_state": trade_state}
|
||||
|
||||
except Exception as wx_e:
|
||||
# Mock for testing without keys
|
||||
if "Private key not loaded" in str(wx_e):
|
||||
# Simulate success for testing? No, stick to error.
|
||||
return {"success": False, "message": "配置错误"}
|
||||
return {"success": False, "message": str(wx_e)}
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
@app.post("/api/payment/notify")
|
||||
async def payment_notify(request: Request):
|
||||
# Handle WeChat Pay Callback
|
||||
|
||||
BIN
static/cert/apiclient_cert.p12
Normal file
BIN
static/cert/apiclient_cert.p12
Normal file
Binary file not shown.
25
static/cert/apiclient_cert.pem
Normal file
25
static/cert/apiclient_cert.pem
Normal file
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEITCCAwmgAwIBAgIUSeKZ/C8BhB7NZljYsDfFM4vRcLowDQYJKoZIhvcNAQEL
|
||||
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||
Q0EwHhcNMjYwMTI4MTQxNTE4WhcNMzEwMTI3MTQxNTE4WjB7MRMwEQYDVQQDDAox
|
||||
NzIzNjg1NTExMRswGQYDVQQKDBLlvq7kv6HllYbmiLfns7vnu58xJzAlBgNVBAsM
|
||||
HuS6keWNl+mHj+i/ueenkeaKgOaciemZkOWFrOWPuDELMAkGA1UEBhMCQ04xETAP
|
||||
BgNVBAcMCFNoZW5aaGVuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
|
||||
xxKpTgk0MAbX/cU2zXn9T6uCWToRJzNXiGf45RIcP9mD7wlXdr3p2HQ6Vv3zhDKE
|
||||
goj980THhoaqX3mWXs9jxMNtdXPsjrFXk0I0e0O7BqFl/5pVicoDv3GZ/LASdqwC
|
||||
rLFWZRmmkB45ehb5qoFBTFBmE3SS5qm8v2UbrrRMuu07vM1AHmtw36YyAlGRKaMj
|
||||
fQVr5l0u9769j2F8Rd8/HlyiyjUZjhpRiGuv/n1eEWoL6ZIFGA/BCPgvU+Q+Xm3q
|
||||
6aZd8odO5p2c1knlV/dJfRv2vC9Q8fXLalr3UQF0bM0vFCKn2sWLRYFo9Z+Nep51
|
||||
/S9eUZn+7yhpxoImKBGycQIDAQABo4G5MIG2MAkGA1UdEwQCMAAwCwYDVR0PBAQD
|
||||
AgP4MIGbBgNVHR8EgZMwgZAwgY2ggYqggYeGgYRodHRwOi8vZXZjYS5pdHJ1cy5j
|
||||
b20uY24vcHVibGljL2l0cnVzY3JsP0NBPTFCRDQyMjBFNTBEQkMwNEIwNkFEMzk3
|
||||
NTQ5ODQ2QzAxQzNFOEVCRDImc2c9SEFDQzQ3MUI2NTQyMkUxMkIyN0E5RDMzQTg3
|
||||
QUQxQ0RGNTkyNkUxNDAzNzEwDQYJKoZIhvcNAQELBQADggEBAEqNn5h/g+PNkbwM
|
||||
2sIl8aSgtB1tOgBAQH0sI2zqJbP/nEkeqAS1PL7Eo8EwbVrxXQV7rv75+iF8g3ry
|
||||
DiCGGgooO4F/Ohh3X1BwiSKokzl8sZV6rB/ojHKkfwA0GE9xualqo2sikGpNQyUt
|
||||
BeDp9OC8puBd7LR8KK8fJ7+3e3xcnH+cWZ6IZCRXgVtlofvM3OuzHQs0Bq31B4fp
|
||||
yhuX1Dig/gldeKt2S624tUfOaVVdFROikYFKx8vxcsWmKMZnK8M5UBiJJIE/txvK
|
||||
TT+vRUXdjZ737j4GQ86T6vedn5S88rFFiBv7LsQTIa69CRdD2dGajoTexgh1m+l9
|
||||
LsHf65A=
|
||||
-----END CERTIFICATE-----
|
||||
28
static/cert/apiclient_key.pem
Normal file
28
static/cert/apiclient_key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHEqlOCTQwBtf9
|
||||
xTbNef1Pq4JZOhEnM1eIZ/jlEhw/2YPvCVd2venYdDpW/fOEMoSCiP3zRMeGhqpf
|
||||
eZZez2PEw211c+yOsVeTQjR7Q7sGoWX/mlWJygO/cZn8sBJ2rAKssVZlGaaQHjl6
|
||||
FvmqgUFMUGYTdJLmqby/ZRuutEy67Tu8zUAea3DfpjICUZEpoyN9BWvmXS73vr2P
|
||||
YXxF3z8eXKLKNRmOGlGIa6/+fV4RagvpkgUYD8EI+C9T5D5eberppl3yh07mnZzW
|
||||
SeVX90l9G/a8L1Dx9ctqWvdRAXRszS8UIqfaxYtFgWj1n416nnX9L15Rmf7vKGnG
|
||||
giYoEbJxAgMBAAECggEAf6NzcllsYK7Cpi9gp/ZjeEWbWeJlRSEiKo+fgiBOhJHO
|
||||
ieEPd07lNKexED3beRN66sczLFsZIyQM8RJgW4HVkj9LW1dsgUEryXtVcwNGU0YA
|
||||
xBYMakBgjssj1GSAMh6vyFIb9s6vgRAgAivhAHXHjEqwaCECX2rNXlpRmX55BBAd
|
||||
nMudGbbnb/jE9EUVOEvovrYqfCCxloHejCEY2/XwmU2GpGkDQIklOv2gnsvAWvDu
|
||||
V+jx8OlMDeyoAZ65yIdRzgrX20JmxqyXZPOfbleNmR3F03sgpuv5cZSZVgLU8Hd+
|
||||
CPVDGOV66kupFmsMi5R2rvrExhTp4I0V6s5s+wwNAQKBgQDu7G8BQcUB4NXsKemf
|
||||
D89e5di0BKK5+MvMDGYTRmEMJJ9Ag1iCD11/4eg6iEei1h6TRTxmvDWm3e20KnrK
|
||||
8Ug3oJQf8kYYUGgmIDhvWegfbAw1RNoZ9+I7UYofUzFk4pAdJMMqolc0kXIoOPX7
|
||||
uym/FiDgIqstjNRr+kbLtU8/qQKBgQDVTRU/cE3R0eZMDZ3EvbqD00BzPmK3h3Hs
|
||||
9358oh3v0Xf69p2TH58gVpbJPbHydeUvbmjPc6QeRFT9pVA7dFqXi3lJ4WOu9vu+
|
||||
l2Sn0NX+g7khDzig8lj27RWxmAVc5H//VqeSxi8Z87JWGKGn1bkttN8MGvCSn8UA
|
||||
W3LLfRw5iQKBgQCAmI1etdGdozBA/oYZ8N6Ci1/J9LzJMBow53OBaF4PtR5qEMfw
|
||||
qOiATk06Q+Oax3iJd7h860xNViH82Ohilt9x4WIYl8QWjiCgdLMra86+Kt+fREHH
|
||||
vF3t44NePN7XGALNTN4m8l3Rk39IGvB631Am43gqaz6LM8OZoom8VwgikQKBgEFT
|
||||
4nYTh0ID72zLns5q7X0CbnLdYI6lRRc2ld7GKDUTCpQqhAhTXwEgg/4OjzmbMh5c
|
||||
ymz/FfzPINiukOwkrrpLVVJzXXxw2Jl+9K0RIPlajpC5gLaKgwTdiA9kaAATW8Bw
|
||||
YdJqMHLaWHxV/uzQYG7HPqkOdy/xDv0VBQudvc9BAoGBANIzo192Da7c7BrtDetc
|
||||
C+/sc8B4Y/1mkTDc/LS3b53VfGA+otsCAKZF53ezK8xQrtWONFKF9dWkk/fPERsV
|
||||
qgfg3WQIiaobDpLbjgbIOkcP/PX2H0HqhrRofDBt6QiTANBuh6FK4KOPnklXuYPp
|
||||
8Sj4YnidN9RxTxPdIQuoQKaV
|
||||
-----END PRIVATE KEY-----
|
||||
1
static/js/qrcode.min.js
vendored
Normal file
1
static/js/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -73,6 +73,41 @@
|
||||
font-size: 1.2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
h2::after {
|
||||
content: '▼';
|
||||
font-size: 0.8em;
|
||||
transition: transform 0.3s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.card.collapsed h2::after {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.card.collapsed h2 {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
transition: all 0.3s ease-in-out;
|
||||
max-height: 3000px; /* Ensure enough height for content */
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card.collapsed .card-content {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -297,6 +332,7 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>基本信息设置</h2>
|
||||
<div class="card-content">
|
||||
<div class="form-group">
|
||||
<label>活动标题</label>
|
||||
<input type="text" id="event_title">
|
||||
@@ -318,9 +354,11 @@
|
||||
<textarea id="event_content" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsed">
|
||||
<h2>签到设置</h2>
|
||||
<div class="card-content">
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="enable_ticket_validation">
|
||||
<label for="enable_ticket_validation">开启验票验证 (核对数据库与付款记录)</label>
|
||||
@@ -329,9 +367,11 @@
|
||||
关闭后将跳过数据库核查,直接允许签到(若用户不存在将自动创建)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsed">
|
||||
<h2>座位设置</h2>
|
||||
<div class="card-content">
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="enable_seating" onchange="toggleSeatingInputs()">
|
||||
<label for="enable_seating">开启分桌功能</label>
|
||||
@@ -347,9 +387,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>表单字段设置</h2>
|
||||
<div class="card collapsed">
|
||||
<h2>报名输入字段设置 (Registration Fields)</h2>
|
||||
<div class="card-content">
|
||||
<p style="font-size: 0.9em; color: var(--text-muted); margin-bottom: 15px;">对应 gsdh_data 表 (ticket.html)</p>
|
||||
<table style="width: 100%; border-collapse: collapse; color: white;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left;">
|
||||
@@ -358,14 +401,35 @@
|
||||
<th style="padding: 10px;">是否必填</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="field_config_body">
|
||||
<!-- Fields will be injected here -->
|
||||
<tbody id="ticket_field_config_body">
|
||||
<!-- Ticket Fields will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsed">
|
||||
<h2>签到/大屏字段设置 (Check-in Fields)</h2>
|
||||
<div class="card-content">
|
||||
<p style="font-size: 0.9em; color: var(--text-muted); margin-bottom: 15px;">对应 checkin_info 表 (wall/checkin)</p>
|
||||
<table style="width: 100%; border-collapse: collapse; color: white;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left;">
|
||||
<th style="padding: 10px;">字段名称</th>
|
||||
<th style="padding: 10px;">是否显示</th>
|
||||
<th style="padding: 10px;">是否必填</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="checkin_field_config_body">
|
||||
<!-- Checkin Fields will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card collapsed">
|
||||
<h2>支付设置 (Payment)</h2>
|
||||
<div class="card-content">
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="enable_payment">
|
||||
<label for="enable_payment">开启付费报名 (Enable Paid Registration)</label>
|
||||
@@ -408,9 +472,11 @@
|
||||
<input type="text" id="wx_notify_url" placeholder="https://your-domain.com/api/payment/notify">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsed">
|
||||
<h2>数据库设置</h2>
|
||||
<div class="card-content">
|
||||
<div class="form-group">
|
||||
<label>数据库地址 (Host)</label>
|
||||
<input type="text" id="db_host" placeholder="localhost">
|
||||
@@ -440,9 +506,11 @@
|
||||
提示:切换到新数据库后,点击“初始化/迁移结构”可自动创建表结构(不会删除现有数据)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsed">
|
||||
<h2>大屏设置 (Wall Config)</h2>
|
||||
<div class="card-content">
|
||||
<div class="form-group">
|
||||
<label>背景图片透明度 (0-1)</label>
|
||||
<input type="number" id="wall_bg_opacity" step="0.1" min="0" max="1" value="0.3">
|
||||
@@ -473,9 +541,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsed">
|
||||
<h2>主题设置</h2>
|
||||
<div class="card-content">
|
||||
<div class="form-group">
|
||||
<label>主色调 (Primary Color)</label>
|
||||
<input type="color" id="primary_color">
|
||||
@@ -489,9 +559,11 @@
|
||||
<input type="color" id="bg_color">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsed">
|
||||
<h2>图片设置</h2>
|
||||
<div class="card-content">
|
||||
<div class="form-group">
|
||||
<label>顶部头图</label>
|
||||
<input type="file" id="header_image_file" accept="image/*" style="padding: 10px 0;">
|
||||
@@ -499,24 +571,36 @@
|
||||
</div>
|
||||
<button onclick="uploadImage()">上传图片</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 25px; text-align: center;">
|
||||
<button onclick="saveConfig()" class="btn-block btn-lg">保存所有设置</button>
|
||||
</div>
|
||||
|
||||
<div class="card danger-zone">
|
||||
<div class="card danger-zone collapsed">
|
||||
<h2>危险区域</h2>
|
||||
<div class="card-content">
|
||||
<p style="color: #ffcccc; margin-bottom: 15px;">此处操作不可逆,请谨慎操作。</p>
|
||||
<div class="form-group">
|
||||
<button class="btn-danger" onclick="resetDatabase()">重置/创建数据库</button>
|
||||
<p style="font-size: 0.9em; color: #ffcccc; margin-top: 10px;">这将清空所有签到数据并重新创建表结构。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="message"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Collapsible Cards Logic
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.card h2').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
header.parentElement.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Use secret from backend
|
||||
const ACCESS_SECRET = "{{ secret }}";
|
||||
|
||||
@@ -602,15 +686,21 @@
|
||||
document.getElementById('max_per_table').value = config.max_per_table || 10;
|
||||
toggleSeatingInputs();
|
||||
|
||||
// Field Config
|
||||
renderFieldConfig(config.field_config || {
|
||||
// Field Configs
|
||||
renderFieldConfig('ticket_field_config_body', config.ticket_field_config || {
|
||||
"name": {"label": "姓名", "show": true, "required": true},
|
||||
"phone": {"label": "手机号码", "show": true, "required": true},
|
||||
"industry_company": {"label": "单位名称", "show": true, "required": true}
|
||||
}, ["name", "phone", "industry_company"]);
|
||||
|
||||
renderFieldConfig('checkin_field_config_body', config.checkin_field_config || {
|
||||
"name": {"label": "姓名", "show": true, "required": true},
|
||||
"phone": {"label": "手机号码", "show": True, "required": True},
|
||||
"company_name": {"label": "单位名称", "show": true, "required": false},
|
||||
"position": {"label": "职务", "show": true, "required": false},
|
||||
"business_scope": {"label": "公司主要经营 / 业务", "show": true, "required": false},
|
||||
"vision_2026": {"label": "2026年业务愿景", "show": true, "required": false}
|
||||
});
|
||||
}, ["name", "phone", "company_name", "position", "business_scope", "vision_2026"]);
|
||||
|
||||
// DB Config
|
||||
document.getElementById('db_host').value = config.db_host || '';
|
||||
@@ -631,12 +721,14 @@
|
||||
document.getElementById('wall_show_scope').checked = show.business_scope !== false;
|
||||
});
|
||||
|
||||
function renderFieldConfig(fieldConfig) {
|
||||
const tbody = document.getElementById('field_config_body');
|
||||
function renderFieldConfig(tbodyId, fieldConfig, orderedKeys) {
|
||||
const tbody = document.getElementById(tbodyId);
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Order of fields
|
||||
const orderedKeys = ["name", "phone", "company_name", "position", "business_scope", "vision_2026"];
|
||||
// If orderedKeys not provided, use default order or Object.keys
|
||||
if (!orderedKeys) {
|
||||
orderedKeys = Object.keys(fieldConfig);
|
||||
}
|
||||
|
||||
orderedKeys.forEach(key => {
|
||||
if (!fieldConfig[key]) return;
|
||||
@@ -644,9 +736,7 @@
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.borderBottom = '1px solid rgba(255,255,255,0.05)';
|
||||
|
||||
// Name/Phone lock logic (can modify required/show but maybe should lock show for logic safety?
|
||||
// User asked to set displayed/required, so I will allow it but maybe warn or just allow it.)
|
||||
// Actually, name/phone are critical. I will disable the 'show' checkbox for them to prevent system break.
|
||||
// Name/Phone lock logic
|
||||
const isLocked = (key === 'name' || key === 'phone');
|
||||
|
||||
tr.innerHTML = `
|
||||
@@ -662,16 +752,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getFieldConfig() {
|
||||
function getFieldConfig(tbodyId) {
|
||||
const config = {};
|
||||
const rows = document.querySelectorAll('#field_config_body tr');
|
||||
const rows = document.querySelectorAll(`#${tbodyId} tr`);
|
||||
rows.forEach(row => {
|
||||
const showCb = row.querySelector('.field-show');
|
||||
const reqCb = row.querySelector('.field-required');
|
||||
const key = showCb.dataset.key;
|
||||
|
||||
// Retrieve label from somewhere or just hardcode/store in data attr.
|
||||
// Simple way: text content
|
||||
const labelText = row.cells[0].textContent.split(' (')[0];
|
||||
|
||||
config[key] = {
|
||||
@@ -680,7 +768,7 @@
|
||||
required: reqCb.checked
|
||||
};
|
||||
});
|
||||
// Ensure locked fields are true/true if disabled
|
||||
// Ensure locked fields are true/true
|
||||
if (config.name) { config.name.show = true; config.name.required = true; }
|
||||
if (config.phone) { config.phone.show = true; config.phone.required = true; }
|
||||
|
||||
@@ -728,7 +816,8 @@
|
||||
max_per_table: parseInt(document.getElementById('max_per_table').value),
|
||||
|
||||
// Field Config
|
||||
field_config: getFieldConfig(),
|
||||
ticket_field_config: getFieldConfig('ticket_field_config_body'),
|
||||
checkin_field_config: getFieldConfig('checkin_field_config_body'),
|
||||
|
||||
// Wall Config
|
||||
wall_config: {
|
||||
|
||||
104
templates/success.html
Normal file
104
templates/success.html
Normal file
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>报名成功 - {{ config.event_title }}</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: {{ config.primary_color }};
|
||||
--secondary-color: {{ config.secondary_color }};
|
||||
--bg-color: {{ config.bg_color }};
|
||||
--card-bg: rgba(12, 24, 50, 0.7);
|
||||
--text-color: #ffffff;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
background-image:
|
||||
radial-gradient(circle at 50% 0%, #1a3a75 0%, #050814 60%),
|
||||
radial-gradient(circle at 85% 30%, rgba(0, 242, 255, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 15% 70%, rgba(0, 102, 255, 0.15) 0%, transparent 40%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 0 20px rgba(40, 167, 69, 0.4);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 15px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #ccc;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 25px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 25px rgba(0, 242, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon-success">✓</div>
|
||||
<h1>报名成功</h1>
|
||||
<p>您已成功支付并完成报名!<br>请届时凭手机号或二维码入场。</p>
|
||||
<a href="/" class="btn">返回首页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>购票报名 - {{ config.event_title }}</title>
|
||||
<script src="/static/js/qrcode.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: {{ config.primary_color }};
|
||||
@@ -191,6 +192,60 @@
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
max-width: 90%;
|
||||
width: 400px;
|
||||
position: relative;
|
||||
box-shadow: 0 0 30px rgba(0, 242, 255, 0.2);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#qrcode {
|
||||
margin: 20px auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-tip {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -206,26 +261,20 @@
|
||||
</div>
|
||||
|
||||
<form id="paymentForm" onsubmit="handlePayment(event)">
|
||||
{% set ordered_keys = ['name', 'phone', 'industry_company'] %}
|
||||
{% for key in ordered_keys %}
|
||||
{% set field = config.ticket_field_config.get(key) %}
|
||||
{% if field and field.show %}
|
||||
<div class="form-group">
|
||||
<label>姓名 *</label>
|
||||
<input type="text" id="name" required placeholder="请输入您的姓名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>手机号码 *</label>
|
||||
<input type="tel" id="phone" required placeholder="请输入您的手机号码" pattern="[0-9]{11}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>单位名称</label>
|
||||
<input type="text" id="company_name" placeholder="请输入单位名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>职务</label>
|
||||
<input type="text" id="position" placeholder="请输入您的职务">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>业务范围</label>
|
||||
<input type="text" id="business_scope" placeholder="简述主要业务">
|
||||
<label>{{ field.label }}{% if field.required %} *{% endif %}</label>
|
||||
{% if key == 'phone' %}
|
||||
<input type="tel" id="{{ key }}" {% if field.required %}required{% endif %} placeholder="请输入{{ field.label }}" pattern="[0-9]{11}">
|
||||
{% else %}
|
||||
<input type="text" id="{{ key }}" {% if field.required %}required{% endif %} placeholder="请输入{{ field.label }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn">
|
||||
立即支付报名
|
||||
@@ -236,17 +285,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Modal -->
|
||||
<div id="qrModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<span class="modal-close" onclick="closeModal()">×</span>
|
||||
<div class="modal-title">微信扫码支付</div>
|
||||
<div style="margin: 15px 0; color: #28a745; font-weight: bold; font-size: 1.1rem;">
|
||||
<span style="display:inline-block; margin-right:5px; vertical-align:middle;">📱</span>
|
||||
请使用微信“扫一扫”
|
||||
</div>
|
||||
<div id="qrcode"></div>
|
||||
<div id="payment-status" style="color: #666; font-size: 14px; margin-top: 15px;">等待支付...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let pollTimer = null;
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('qrModal').style.display = 'none';
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
document.getElementById('qrcode').innerHTML = '';
|
||||
|
||||
// Reset button state
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '立即支付报名';
|
||||
}
|
||||
|
||||
async function handlePayment(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const msg = document.getElementById('message');
|
||||
const originalText = btn.innerHTML;
|
||||
const originalText = "立即支付报名"; // Hardcoded or get from element
|
||||
|
||||
// Validate
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const phone = document.getElementById('phone').value.trim();
|
||||
const getVal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value.trim() : '';
|
||||
};
|
||||
|
||||
const name = getVal('name');
|
||||
const phone = getVal('phone');
|
||||
|
||||
if (!name || !phone) {
|
||||
showMessage('请填写必填项', 'error');
|
||||
@@ -261,13 +342,15 @@
|
||||
const data = {
|
||||
name: name,
|
||||
phone: phone,
|
||||
company_name: document.getElementById('company_name').value.trim(),
|
||||
position: document.getElementById('position').value.trim(),
|
||||
business_scope: document.getElementById('business_scope').value.trim()
|
||||
company_name: getVal('industry_company')
|
||||
};
|
||||
|
||||
// Check Screen Width
|
||||
const isWideScreen = window.innerWidth > 768;
|
||||
const apiEndpoint = isWideScreen ? '/api/payment/native' : '/api/payment/h5';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/payment/h5', {
|
||||
const res = await fetch(apiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -277,10 +360,27 @@
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success && result.h5_url) {
|
||||
if (result.success) {
|
||||
if (isWideScreen && result.code_url) {
|
||||
// Show Native Pay QR Code
|
||||
document.getElementById('qrModal').style.display = 'flex';
|
||||
document.getElementById('qrcode').innerHTML = ''; // Clear previous
|
||||
new QRCode(document.getElementById("qrcode"), {
|
||||
text: result.code_url,
|
||||
width: 200,
|
||||
height: 200
|
||||
});
|
||||
|
||||
// Start Polling
|
||||
startPolling(result.out_trade_no);
|
||||
|
||||
} else if (result.h5_url) {
|
||||
// H5 Redirect
|
||||
showMessage('正在跳转支付...', 'success');
|
||||
// Redirect to WeChat Pay
|
||||
window.location.href = result.h5_url;
|
||||
} else {
|
||||
throw new Error('未获取到支付链接');
|
||||
}
|
||||
} else {
|
||||
showMessage(result.message || '支付请求失败', 'error');
|
||||
btn.disabled = false;
|
||||
@@ -288,12 +388,45 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showMessage('网络请求错误,请重试', 'error');
|
||||
showMessage(err.message || '网络请求错误,请重试', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(out_trade_no) {
|
||||
const statusDiv = document.getElementById('payment-status');
|
||||
let attempts = 0;
|
||||
const maxAttempts = 600; // 30 mins max
|
||||
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) {
|
||||
clearInterval(pollTimer);
|
||||
statusDiv.textContent = '支付超时,请刷新重试';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/payment/check/${out_trade_no}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.trade_state === 'SUCCESS') {
|
||||
clearInterval(pollTimer);
|
||||
statusDiv.textContent = '支付成功!正在跳转...';
|
||||
statusDiv.style.color = '#28a745';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/success';
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling error", e);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const msg = document.getElementById('message');
|
||||
msg.textContent = text;
|
||||
|
||||
Reference in New Issue
Block a user