支付功能

This commit is contained in:
jeremygan2021
2026-01-28 22:54:03 +08:00
parent 4c7f8126e8
commit 4597d6fe35
12 changed files with 914 additions and 255 deletions

View File

@@ -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.

View File

@@ -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"
}

View File

@@ -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
View File

@@ -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

Binary file not shown.

View 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-----

View 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

File diff suppressed because one or more lines are too long

View File

@@ -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
View 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>

View File

@@ -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()">&times;</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;