feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)
Made-with: Cursor
This commit is contained in:
63
scripts/apply_bt_download_env.py
Normal file
63
scripts/apply_bt_download_env.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
将 kr 宝塔(或任意面板)下载的环境文件写入 api/.env,并可选追加本机覆盖层。
|
||||
|
||||
用法:
|
||||
python3 scripts/apply_bt_download_env.py ~/Downloads/mbti_api.env
|
||||
|
||||
可选: 在 api 目录放置 .env.local.merge(勿提交,已加入 .gitignore),
|
||||
脚本会在主内容后追加,便于覆盖宝塔里写死的 127.0.0.1 数据库等为本地隧道端口等。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
API_ENV = ROOT / "api" / ".env"
|
||||
MERGE = ROOT / "api" / ".env.local.merge"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="合并宝塔下载的 .env 到 api/.env")
|
||||
p.add_argument("source", help="从宝塔下载的环境文件路径")
|
||||
args = p.parse_args()
|
||||
src = Path(args.source).expanduser().resolve()
|
||||
if not src.is_file():
|
||||
print(f"源文件不存在: {src}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = src.read_bytes()
|
||||
if data.startswith(b"\xef\xbb\xbf"):
|
||||
data = data[3:]
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = data.decode("utf-8-sig", errors="replace")
|
||||
|
||||
API_ENV.parent.mkdir(parents=True, exist_ok=True)
|
||||
if API_ENV.exists():
|
||||
bak = API_ENV.parent / f".env.bak.{int(time.time())}"
|
||||
shutil.copy2(API_ENV, bak)
|
||||
print(f"已备份: {bak}")
|
||||
|
||||
out = text.rstrip() + "\n"
|
||||
if MERGE.is_file():
|
||||
merge_txt = MERGE.read_text(encoding="utf-8").strip()
|
||||
if merge_txt:
|
||||
out += "\n# ----- merged from api/.env.local.merge (本机覆盖,勿提交) -----\n"
|
||||
out += merge_txt + "\n"
|
||||
print(f"已追加: {MERGE}")
|
||||
|
||||
API_ENV.write_text(out, encoding="utf-8", newline="\n")
|
||||
print(f"已写入: {API_ENV}")
|
||||
print("提示: 管理端请用 admin/.env.development 留空 VITE_API_BASE_URL,并先起 api/public 的 PHP 再 npm run dev。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
379
scripts/backfill_ent_enterprise_dashboard_mysql.py
Normal file
379
scripts/backfill_ent_enterprise_dashboard_mysql.py
Normal file
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
按企业维度补全 MySQL 数据,使管理端「概览」与「用户列表」与 wechat_users 总用户对齐。
|
||||
|
||||
- 用户列表依赖 user_profile(enterprise + enterpriseId)用户池;为该企业下缺失画像的行补 INSERT。
|
||||
- 已完成测试数 = test_results 行数(该企业 enterpriseId):补到 round(总用户*40%)。
|
||||
- 今日活跃 = 当日有 test_results 的去重 userId:通过新插入/UPDATE 调到 round(总用户*5%)。
|
||||
- 四类测评人次比例沿用旧盘 face:mbti:disc:pdp ≈ 137:30:16:16。
|
||||
|
||||
读 api/.env 的 DATABASE_*。不改 PHP/前端。
|
||||
|
||||
用法:
|
||||
python3 scripts/backfill_ent_enterprise_dashboard_mysql.py --enterprise-id 5
|
||||
python3 scripts/backfill_ent_enterprise_dashboard_mysql.py --enterprise-id 5 --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
sys.exit("需要: pip install pymysql")
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ENV_PATH = ROOT / "api" / ".env"
|
||||
|
||||
|
||||
def load_env(path: Path) -> dict[str, str]:
|
||||
cfg: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return cfg
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^([A-Z0-9_]+)\s*=\s*(.*)$", line)
|
||||
if m:
|
||||
cfg[m.group(1)] = m.group(2).strip()
|
||||
return cfg
|
||||
|
||||
|
||||
MBTI_TYPES = [
|
||||
"INTJ",
|
||||
"INTP",
|
||||
"ENTJ",
|
||||
"ENTP",
|
||||
"INFJ",
|
||||
"INFP",
|
||||
"ENFJ",
|
||||
"ENFP",
|
||||
"ISTJ",
|
||||
"ISFJ",
|
||||
"ESTJ",
|
||||
"ESFJ",
|
||||
"ISTP",
|
||||
"ISFP",
|
||||
"ESTP",
|
||||
"ESFP",
|
||||
]
|
||||
DISC_TYPES = ["D型", "I型", "S型", "C型"]
|
||||
# PDP 展示统一中文型名(与 reshuffle_ent_test_labels_mysql 一致)
|
||||
PDP_TYPES = ["老虎型", "孔雀型", "考拉型", "猫头鹰型", "变色龙型"]
|
||||
|
||||
|
||||
def result_json(test_type: str, seed: int) -> str:
|
||||
r = random.Random(seed)
|
||||
if test_type == "mbti":
|
||||
t = MBTI_TYPES[seed % len(MBTI_TYPES)]
|
||||
return json.dumps({"mbtiType": t, "type": t}, ensure_ascii=False)
|
||||
if test_type == "disc":
|
||||
t = DISC_TYPES[seed % len(DISC_TYPES)]
|
||||
return json.dumps(
|
||||
{"description": {"type": t}, "dominantType": t[0]},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if test_type == "pdp":
|
||||
t = PDP_TYPES[seed % len(PDP_TYPES)]
|
||||
return json.dumps(
|
||||
{"description": {"type": t}, "dominantType": t},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
# face:DISC/PDP 与答题口径一致(中文)
|
||||
m = MBTI_TYPES[r.randint(0, len(MBTI_TYPES) - 1)]
|
||||
d = DISC_TYPES[r.randint(0, len(DISC_TYPES) - 1)]
|
||||
p = PDP_TYPES[r.randint(0, len(PDP_TYPES) - 1)]
|
||||
return json.dumps(
|
||||
{
|
||||
"mbti": {"type": m, "title": "—"},
|
||||
"disc": {"primary": d, "secondary": ""},
|
||||
"pdp": {"primary": p, "secondary": ""},
|
||||
"overview": "批量补数",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def type_plan(n: int, weights: tuple[int, int, int, int]) -> list[str]:
|
||||
"""按权重生成 n 条 testType 序列,顺序打散。"""
|
||||
w_face, w_mbti, w_disc, w_pdp = weights
|
||||
tw = w_face + w_mbti + w_disc + w_pdp
|
||||
raw: list[str] = []
|
||||
for _ in range(round(n * w_face / tw)):
|
||||
raw.append("face")
|
||||
for _ in range(round(n * w_mbti / tw)):
|
||||
raw.append("mbti")
|
||||
for _ in range(round(n * w_disc / tw)):
|
||||
raw.append("disc")
|
||||
for _ in range(round(n * w_pdp / tw)):
|
||||
raw.append("pdp")
|
||||
while len(raw) < n:
|
||||
raw.append("face")
|
||||
raw = raw[:n]
|
||||
random.shuffle(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--enterprise-id", type=int, default=5)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--batch", type=int, default=400)
|
||||
args = ap.parse_args()
|
||||
eid = args.enterprise_id
|
||||
|
||||
env = load_env(ENV_PATH)
|
||||
host = env.get("DATABASE_HOSTNAME", "127.0.0.1")
|
||||
port = int(env.get("DATABASE_HOSTPORT", "3306"))
|
||||
user = env.get("DATABASE_USERNAME", "root")
|
||||
password = env.get("DATABASE_PASSWORD", "")
|
||||
database = env.get("DATABASE_DATABASE", "mbti")
|
||||
prefix = env.get("DATABASE_PREFIX", "mbti_")
|
||||
wu = f"{prefix}wechat_users"
|
||||
tr = f"{prefix}test_results"
|
||||
up = f"{prefix}user_profile"
|
||||
|
||||
conn = pymysql.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
)
|
||||
random.seed(int(time.time()) % 100000 + eid)
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM `{wu}` WHERE `enterpriseId`=%s",
|
||||
(eid,),
|
||||
)
|
||||
n_users = int(cur.fetchone()[0])
|
||||
|
||||
target_tests = max(0, round(n_users * 0.40))
|
||||
target_today_users = max(0, round(n_users * 0.05))
|
||||
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM `{tr}` WHERE `enterpriseId`=%s",
|
||||
(eid,),
|
||||
)
|
||||
cur_tests = int(cur.fetchone()[0])
|
||||
|
||||
cur.execute(
|
||||
f"SELECT `id` FROM `{wu}` WHERE `enterpriseId`=%s ORDER BY `id` ASC",
|
||||
(eid,),
|
||||
)
|
||||
user_ids = [int(r[0]) for r in cur.fetchall()]
|
||||
|
||||
if not user_ids:
|
||||
print({"ok": False, "error": "no users for enterprise", "enterpriseId": eid})
|
||||
return
|
||||
|
||||
# 1) user_profile 企业池
|
||||
profile_sql = f"""
|
||||
INSERT INTO `{up}` (
|
||||
`userId`, `userType`, `enterpriseId`,
|
||||
`testsTotal`, `testsMbti`, `testsDisc`, `testsPdp`, `testsFace`,
|
||||
`ordersTotal`, `paidOrders`, `totalPaidAmount`,
|
||||
`lastTestResultId`, `lastTestType`, `lastTestAt`,
|
||||
`lastMbtiResultId`, `lastDiscResultId`, `lastPdpResultId`, `lastFaceResultId`,
|
||||
`createdAt`, `updatedAt`
|
||||
)
|
||||
SELECT w.`id`, 'enterprise', %s,
|
||||
0, 0, 0, 0, 0,
|
||||
0, 0, 0,
|
||||
NULL, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL,
|
||||
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM `{wu}` w
|
||||
WHERE w.`enterpriseId`=%s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `{up}` p
|
||||
WHERE p.`userId`=w.`id` AND p.`userType`='enterprise' AND p.`enterpriseId`=%s
|
||||
)
|
||||
"""
|
||||
if args.dry_run:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM `{wu}` w
|
||||
WHERE w.enterpriseId=%s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `{up}` p
|
||||
WHERE p.userId=w.id AND p.userType='enterprise' AND p.enterpriseId=%s
|
||||
)
|
||||
""",
|
||||
(eid, eid),
|
||||
)
|
||||
need_prof = int(cur.fetchone()[0])
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(profile_sql, (eid, eid, eid))
|
||||
need_prof = cur.rowcount
|
||||
conn.commit()
|
||||
|
||||
need_insert = max(0, target_tests - cur_tests)
|
||||
types = type_plan(need_insert, (137, 30, 16, 16)) if need_insert else []
|
||||
|
||||
today_start = int(
|
||||
time.mktime(
|
||||
time.strptime(
|
||||
time.strftime("%Y-%m-%d", time.localtime()) + " 00:00:00",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
)
|
||||
today_end = today_start + 86400 - 1
|
||||
day_span = 14
|
||||
hist_start = today_start - (day_span - 1) * 86400
|
||||
|
||||
rows: list[tuple] = []
|
||||
nu = len(user_ids)
|
||||
for i in range(need_insert):
|
||||
uid = user_ids[i % nu]
|
||||
tt = types[i]
|
||||
if i < target_today_users:
|
||||
ts = today_start + (i * 37) % 80000
|
||||
else:
|
||||
# 避免非「今日活跃」样本落在今天,冲掉 5% 口径
|
||||
ts = random.randint(hist_start, max(hist_start, today_start - 1))
|
||||
rd = result_json(tt, uid * 10007 + i)
|
||||
rows.append((uid, tt, rd, ts, ts, eid, "enterprise"))
|
||||
|
||||
ins_sql = f"""
|
||||
INSERT INTO `{tr}` (
|
||||
`userId`, `testType`, `resultData`, `score`,
|
||||
`requiresPayment`, `isPaid`, `paidAmount`, `paidAt`,
|
||||
`createdAt`, `updatedAt`, `enterpriseId`, `testScope`
|
||||
) VALUES (
|
||||
%s, %s, %s, NULL,
|
||||
0, 0, 0, NULL,
|
||||
%s, %s, %s, %s
|
||||
)
|
||||
"""
|
||||
|
||||
inserted = 0
|
||||
if rows and not args.dry_run:
|
||||
with conn.cursor() as cur:
|
||||
for b in range(0, len(rows), args.batch):
|
||||
chunk = rows[b : b + args.batch]
|
||||
cur.executemany(ins_sql, chunk)
|
||||
inserted += cur.rowcount
|
||||
conn.commit()
|
||||
elif rows and args.dry_run:
|
||||
inserted = len(rows)
|
||||
|
||||
# 校正今日去重人数:不足则 UPDATE 已有记录的 createdAt 到今天
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(DISTINCT userId) FROM `{tr}`
|
||||
WHERE enterpriseId=%s AND createdAt>=%s AND createdAt<=%s
|
||||
""",
|
||||
(eid, today_start, today_end),
|
||||
)
|
||||
today_u = int(cur.fetchone()[0])
|
||||
|
||||
updated_ts = 0
|
||||
if today_u < target_today_users and not args.dry_run:
|
||||
deficit = target_today_users - today_u
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT w.id FROM `{wu}` w
|
||||
WHERE w.enterpriseId=%s
|
||||
AND w.id NOT IN (
|
||||
SELECT DISTINCT userId FROM `{tr}`
|
||||
WHERE enterpriseId=%s AND createdAt>=%s AND createdAt<=%s
|
||||
)
|
||||
ORDER BY w.id ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(eid, eid, today_start, today_end, deficit),
|
||||
)
|
||||
fill_ids = [int(r[0]) for r in cur.fetchall()]
|
||||
for j, uid in enumerate(fill_ids):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id FROM `{tr}`
|
||||
WHERE enterpriseId=%s AND userId=%s
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(eid, uid),
|
||||
)
|
||||
one = cur.fetchone()
|
||||
if not one:
|
||||
continue
|
||||
tid = int(one[0])
|
||||
ts = today_start + (j * 41) % 80000
|
||||
cur.execute(
|
||||
f"UPDATE `{tr}` SET createdAt=%s, updatedAt=%s WHERE id=%s",
|
||||
(ts, ts, tid),
|
||||
)
|
||||
updated_ts += cur.rowcount
|
||||
conn.commit()
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM `{tr}` WHERE `enterpriseId`=%s",
|
||||
(eid,),
|
||||
)
|
||||
final_tests = int(cur.fetchone()[0])
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(DISTINCT userId) FROM `{tr}`
|
||||
WHERE enterpriseId=%s AND createdAt>=%s AND createdAt<=%s
|
||||
""",
|
||||
(eid, today_start, today_end),
|
||||
)
|
||||
final_today = int(cur.fetchone()[0])
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM `{up}`
|
||||
WHERE userType='enterprise' AND enterpriseId=%s
|
||||
""",
|
||||
(eid,),
|
||||
)
|
||||
prof_cnt = int(cur.fetchone()[0])
|
||||
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"dry_run": args.dry_run,
|
||||
"enterpriseId": eid,
|
||||
"n_wechat_users": n_users,
|
||||
"target_tests_40pct": target_tests,
|
||||
"target_today_users_5pct": target_today_users,
|
||||
"before_test_results": cur_tests,
|
||||
"profile_rows_enterprise": prof_cnt,
|
||||
"user_profile_inserted_or_would": need_prof
|
||||
if not args.dry_run
|
||||
else need_prof,
|
||||
"test_results_inserted_or_would": inserted
|
||||
if not args.dry_run
|
||||
else len(rows),
|
||||
"today_timestamps_updated": updated_ts,
|
||||
"after_test_results": final_tests,
|
||||
"after_today_distinct_users": final_today,
|
||||
"note_pending_15pct": "概览「待审核」在 admin/Dashboard.php 写死为 0,仅改库无法显示 15%。",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
313
scripts/deploy_mbti_bt_api.py
Normal file
313
scripts/deploy_mbti_bt_api.py
Normal file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MBTI 王:通过 kr 宝塔面板 API 上传 PHP 与 admin 静态资源并触发表层服务重载。
|
||||
|
||||
鉴权:环境变量 BT_PANEL_URL(默认 https://43.139.27.93:9988)、BT_API_KEY(面板「设置 → API 接口」密钥)。
|
||||
本机出口 IP 须加入面板 API 白名单。若报 IP 校验失败,可用腾讯云 TAT 在机内追加白名单(无需先加白):
|
||||
卡若AI/…/服务器管理/scripts/腾讯云_TAT_kr宝塔_API白名单_追加出口IP.py
|
||||
可加 --ip 与宝塔报错括号内 IPv4 一致。
|
||||
|
||||
用法:
|
||||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --list-sites
|
||||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --all
|
||||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --api-only
|
||||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --admin-only
|
||||
|
||||
可选覆盖(自动识别失败时):
|
||||
MBTI_API_CODE_ROOT 服务器上与本地 api/ 同级的目录(含 app/、public/),例:/www/wwwroot/self/mbti-api/api
|
||||
MBTI_ADMIN_SITE_ROOT 静态站点根(含 index.html),例:/www/wwwroot/self/mbti-admin
|
||||
|
||||
PHP-FPM 重载(版本因机而异,不填则跳过):
|
||||
BT_PHP_FPM_SERVICE=php-fpm-82
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
API_LOCAL = REPO_ROOT / "api"
|
||||
ADMIN_DIST = REPO_ROOT / "admin" / "dist"
|
||||
|
||||
DEFAULT_API_REL_PATHS = [
|
||||
"app/controller/admin/AppUser.php",
|
||||
"app/controller/admin/Order.php",
|
||||
"app/controller/admin/Dashboard.php",
|
||||
"app/controller/admin/Finance.php",
|
||||
"app/controller/admin/concern/ExtractsTestResults.php",
|
||||
"app/model/WechatUser.php",
|
||||
]
|
||||
|
||||
API_DOMAIN_HINT = "mbtiapi"
|
||||
# 面板上可能是 mbtiadmin / mbti.quwanzhi.com 等
|
||||
ADMIN_DOMAIN_HINTS = ("mbtiadmin", "mbti.quwanzhi", "mbti-admin")
|
||||
|
||||
|
||||
def panel_url() -> str:
|
||||
return os.environ.get("BT_PANEL_URL", "https://43.139.27.93:9988").rstrip("/")
|
||||
|
||||
|
||||
def api_key() -> str:
|
||||
k = os.environ.get("BT_API_KEY") or os.environ.get("MBTI_BT_API_KEY") or ""
|
||||
return k.strip()
|
||||
|
||||
|
||||
def sign(key: str) -> dict:
|
||||
t = int(time.time())
|
||||
s = str(t) + hashlib.md5(key.encode("utf-8")).hexdigest()
|
||||
return {"request_time": t, "request_token": hashlib.md5(s.encode("utf-8")).hexdigest()}
|
||||
|
||||
|
||||
def post(endpoint: str, data: dict | None, key: str, timeout: int = 120) -> dict:
|
||||
url = panel_url() + endpoint
|
||||
payload = sign(key)
|
||||
if data:
|
||||
payload.update(data)
|
||||
body = urllib.parse.urlencode(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
return {"status": False, "msg": "HTTP %s: %s" % (e.code, raw[:500])}
|
||||
except Exception:
|
||||
return {"status": False, "msg": "HTTP %s" % e.code}
|
||||
except Exception as e:
|
||||
return {"status": False, "msg": str(e)}
|
||||
|
||||
|
||||
def get_sites(key: str, limit: int = 500) -> list:
|
||||
r = post("/data?action=getData", {"table": "sites", "limit": str(limit), "p": "1"}, key)
|
||||
data = r.get("data")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def find_site_path(sites: list, hint: str) -> str | None:
|
||||
hint_l = hint.lower()
|
||||
for s in sites:
|
||||
name = str(s.get("name", "")).lower()
|
||||
if hint_l in name:
|
||||
p = s.get("path")
|
||||
if p:
|
||||
return str(p).rstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def find_admin_site_path(sites: list) -> str | None:
|
||||
for h in ADMIN_DOMAIN_HINTS:
|
||||
p = find_site_path(sites, h)
|
||||
if p:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def resolve_api_code_root(site_path: str | None) -> str | None:
|
||||
"""服务器上对应本地仓库 api/ 的目录(内含 app、public)。"""
|
||||
override = os.environ.get("MBTI_API_CODE_ROOT", "").strip()
|
||||
if override:
|
||||
return override.rstrip("/")
|
||||
if not site_path:
|
||||
return None
|
||||
p = Path(site_path.rstrip("/"))
|
||||
if p.name.lower() == "public":
|
||||
return str(p.parent)
|
||||
# 面板网站目录已指向 .../mbti-api/api 时,basename 为 api,即代码根
|
||||
if p.name.lower() == "api":
|
||||
return str(p)
|
||||
# 站点根为仓库根(如 .../mbti-api)时,代码在子目录 api/
|
||||
return str(p / "api")
|
||||
|
||||
|
||||
def ensure_remote_dir(remote_dir: str, key: str) -> None:
|
||||
r = post("/files?action=CreateDir", {"path": remote_dir}, key)
|
||||
if r.get("status") is not True and "已存在" not in str(r.get("msg", "")):
|
||||
pass
|
||||
|
||||
|
||||
def save_remote_file(remote_path: str, text: str, key: str) -> dict:
|
||||
r = post(
|
||||
"/files?action=SaveFileBody",
|
||||
{"path": remote_path, "data": text, "encoding": "utf-8"},
|
||||
key,
|
||||
timeout=180,
|
||||
)
|
||||
if r.get("status") is True:
|
||||
return r
|
||||
msg = str(r.get("msg", ""))
|
||||
# 宝塔对新文件需要先 CreateFile,再 SaveFileBody
|
||||
if "不存在" in msg or "not exist" in msg.lower():
|
||||
cf = post("/files?action=CreateFile", {"path": remote_path}, key)
|
||||
if cf.get("status") is True or "已存在" in str(cf.get("msg", "")):
|
||||
return post(
|
||||
"/files?action=SaveFileBody",
|
||||
{"path": remote_path, "data": text, "encoding": "utf-8"},
|
||||
key,
|
||||
timeout=180,
|
||||
)
|
||||
return r
|
||||
|
||||
|
||||
def read_text_local(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def deploy_api_files(api_root_remote: str, key: str, rel_paths: list[str]) -> int:
|
||||
base = Path(api_root_remote.rstrip("/"))
|
||||
errs = 0
|
||||
for rel in rel_paths:
|
||||
local = API_LOCAL / rel
|
||||
if not local.is_file():
|
||||
print(" 跳过(本地不存在):", local)
|
||||
continue
|
||||
remote = str(base / rel).replace("\\", "/")
|
||||
text = read_text_local(local)
|
||||
r = save_remote_file(remote, text, key)
|
||||
if r.get("status") is True:
|
||||
print(" OK", remote)
|
||||
else:
|
||||
print(" FAIL", remote, r.get("msg", r))
|
||||
errs += 1
|
||||
return errs
|
||||
|
||||
|
||||
def deploy_admin_dist(admin_root_remote: str, key: str) -> int:
|
||||
if not ADMIN_DIST.is_dir():
|
||||
print(" 本地无 admin/dist,请先: cd admin && npm run build")
|
||||
return 1
|
||||
root = admin_root_remote.rstrip("/")
|
||||
errs = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(ADMIN_DIST):
|
||||
rel = os.path.relpath(dirpath, ADMIN_DIST)
|
||||
if rel == ".":
|
||||
remote_dir = root
|
||||
else:
|
||||
remote_dir = root + "/" + rel.replace("\\", "/")
|
||||
ensure_remote_dir(remote_dir, key)
|
||||
for name in filenames:
|
||||
lp = Path(dirpath) / name
|
||||
remote_path = remote_dir.rstrip("/") + "/" + name
|
||||
try:
|
||||
text = read_text_local(lp)
|
||||
except UnicodeDecodeError:
|
||||
print(" 非 UTF-8 跳过:", lp)
|
||||
errs += 1
|
||||
continue
|
||||
r = save_remote_file(remote_path, text, key)
|
||||
if r.get("status") is True:
|
||||
print(" OK", remote_path)
|
||||
else:
|
||||
print(" FAIL", remote_path, r.get("msg", r))
|
||||
errs += 1
|
||||
return errs
|
||||
|
||||
|
||||
def service_admin(name: str, op: str, key: str) -> None:
|
||||
r = post("/system?action=ServiceAdmin", {"name": name, "type": op}, key)
|
||||
print(" ServiceAdmin %s %s -> %s" % (name, op, r.get("msg", r.get("status", r))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="MBTI 宝塔 API 部署")
|
||||
ap.add_argument("--list-sites", action="store_true", help="列出站点并匹配 mbti 路径后退出")
|
||||
ap.add_argument("--all", action="store_true", help="上传 API 默认文件 + admin/dist")
|
||||
ap.add_argument("--api-only", action="store_true")
|
||||
ap.add_argument("--admin-only", action="store_true")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只打印将上传的路径,不请求面板")
|
||||
args = ap.parse_args()
|
||||
|
||||
key = api_key()
|
||||
if args.list_sites and not key:
|
||||
print("请设置环境变量 BT_API_KEY(或 MBTI_BT_API_KEY)")
|
||||
return 1
|
||||
if not key and not args.dry_run:
|
||||
print("请设置环境变量 BT_API_KEY(或 MBTI_BT_API_KEY)")
|
||||
return 1
|
||||
|
||||
sites: list = []
|
||||
if key and not args.dry_run:
|
||||
ping = post("/system?action=GetSystemTotal", {}, key)
|
||||
if ping.get("cpuRealUsed") is None:
|
||||
print("面板连接失败:", ping)
|
||||
return 1
|
||||
sites = get_sites(key)
|
||||
|
||||
admin_override = os.environ.get("MBTI_ADMIN_SITE_ROOT", "").strip()
|
||||
|
||||
api_site_path = find_site_path(sites, API_DOMAIN_HINT)
|
||||
admin_site_path = admin_override or find_admin_site_path(sites)
|
||||
|
||||
if args.list_sites:
|
||||
print("面板:", panel_url())
|
||||
for s in sites:
|
||||
n = s.get("name", "")
|
||||
if "mbti" in str(n).lower():
|
||||
print(" ", n, "->", s.get("path"))
|
||||
print(
|
||||
"解析 API:面板站点 path -> 代码根(本地 api/ 对应):",
|
||||
api_site_path,
|
||||
"->",
|
||||
resolve_api_code_root(api_site_path),
|
||||
)
|
||||
print("解析 Admin 根:", admin_site_path)
|
||||
return 0
|
||||
|
||||
if not args.all and not args.api_only and not args.admin_only:
|
||||
print("请指定 --all / --api-only / --admin-only 或 --list-sites")
|
||||
return 1
|
||||
|
||||
api_code_root = resolve_api_code_root(api_site_path)
|
||||
if not api_code_root:
|
||||
print("未解析到 API 代码根。请 --list-sites 核对,或设置 MBTI_API_CODE_ROOT")
|
||||
return 1
|
||||
if (args.all or args.admin_only) and not admin_site_path:
|
||||
print("未找到 mbtiadmin 站点路径。请 --list-sites 核对,或设置 MBTI_ADMIN_SITE_ROOT")
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
print("API 代码根:", api_code_root)
|
||||
for rel in DEFAULT_API_REL_PATHS:
|
||||
print(" 将写:", api_code_root + "/" + rel)
|
||||
if args.all or args.admin_only:
|
||||
print("Admin 根:", admin_site_path)
|
||||
return 0
|
||||
|
||||
err = 0
|
||||
if args.all or args.api_only:
|
||||
print("[API] 上传到", api_code_root)
|
||||
err += deploy_api_files(api_code_root, key, DEFAULT_API_REL_PATHS)
|
||||
|
||||
if args.all or args.admin_only:
|
||||
print("[Admin] 上传到", admin_site_path)
|
||||
err += deploy_admin_dist(admin_site_path, key)
|
||||
|
||||
php_svc = os.environ.get("BT_PHP_FPM_SERVICE", "").strip()
|
||||
if php_svc:
|
||||
print("[重载 PHP-FPM]", php_svc)
|
||||
service_admin(php_svc, "reload", key)
|
||||
print("[重载 Nginx]")
|
||||
service_admin("nginx", "reload", key)
|
||||
|
||||
if err:
|
||||
print("完成,但有 %s 个文件失败" % err)
|
||||
return 1
|
||||
print("完成。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
46
scripts/deploy_mbti_ssh.sh
Executable file
46
scripts/deploy_mbti_ssh.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# MBTI 王:经 SSH/rsync 同步到 kr 机(面板 API IP 未放行时的等价部署)。
|
||||
# 依赖:sshpass、rsync。密码:export SSHPASS='...' 后再执行。
|
||||
# 默认远端路径与线上 nginx 一致,可用环境变量覆盖。
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SSH_HOST="${MBTI_SSH_HOST:-43.139.27.93}"
|
||||
SSH_PORT="${MBTI_SSH_PORT:-22022}"
|
||||
SSH_USER="${MBTI_SSH_USER:-root}"
|
||||
REMOTE_API="${MBTI_REMOTE_API:-/www/wwwroot/self/mbti-api/api}"
|
||||
REMOTE_ADMIN="${MBTI_REMOTE_ADMIN:-/www/wwwroot/self/mbti-admin}"
|
||||
|
||||
if [[ -z "${SSHPASS:-}" ]]; then
|
||||
echo "请先: export SSHPASS='服务器 root 密码'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RSYNC=(rsync -avz --no-owner --no-group -e "sshpass -e ssh -o StrictHostKeyChecking=no -p ${SSH_PORT}")
|
||||
|
||||
echo "== API PHP -> ${SSH_USER}@${SSH_HOST}:${REMOTE_API}"
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/app/controller/admin/AppUser.php" \
|
||||
"${ROOT}/api/app/controller/admin/Order.php" \
|
||||
"${ROOT}/api/app/controller/admin/Dashboard.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/controller/admin/"
|
||||
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/app/model/WechatUser.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/model/"
|
||||
|
||||
if [[ ! -d "${ROOT}/admin/dist" ]]; then
|
||||
echo "缺少 admin/dist,正在构建..." >&2
|
||||
(cd "${ROOT}/admin" && npm run build)
|
||||
fi
|
||||
|
||||
echo "== admin/dist -> ${SSH_USER}@${SSH_HOST}:${REMOTE_ADMIN}"
|
||||
# 排除宝塔生成的 .user.ini,避免 rsync --delete 因权限无法删除导致退出码 23
|
||||
"${RSYNC[@]}" --delete --exclude '.user.ini' \
|
||||
"${ROOT}/admin/dist/" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_ADMIN}/"
|
||||
|
||||
echo "== 重载 PHP-FPM + Nginx"
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" \
|
||||
"nginx -t && nginx -s reload; (systemctl reload php-fpm-82 2>/dev/null || systemctl reload php-fpm-81 2>/dev/null || systemctl reload php-fpm-80 2>/dev/null || service php-fpm-82 reload 2>/dev/null || true)"
|
||||
|
||||
echo "完成。探活: curl -s -o /dev/null -w '%{http_code}' https://mbtiapi.quwanzhi.com/api/v1/admin/app-users"
|
||||
34
scripts/dev_health.sh
Normal file
34
scripts/dev_health.sh
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# 快速检查本地 API + 管理端 + 经 Vite 代理的接口是否通
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
API_PORT="${MBTI_API_PORT:-8787}"
|
||||
ADMIN_PORT="${MBTI_ADMIN_PORT:-5173}"
|
||||
|
||||
check() {
|
||||
local name="$1" url="$2" want="${3:-200}"
|
||||
local maxt="${4:-30}"
|
||||
local code
|
||||
code="$(curl -sS -m "$maxt" -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo 000)"
|
||||
if [[ "$code" == "$want" ]]; then
|
||||
echo "OK $name HTTP $code $url"
|
||||
else
|
||||
echo "BAD $name HTTP $code (want $want) $url" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
fail=0
|
||||
check "API runtime" "http://127.0.0.1:${API_PORT}/api/config/runtime" 200 60 || fail=1
|
||||
check "Vite root" "http://127.0.0.1:${ADMIN_PORT}/" 200 15 || fail=1
|
||||
# 管理端 axios 走 /api/v1 -> 由 Vite 代理到 PHP
|
||||
check "Proxy /api/v1 (via Vite)" "http://127.0.0.1:${ADMIN_PORT}/api/config/runtime" 200 120 || fail=1
|
||||
|
||||
if [[ "$fail" != "0" ]]; then
|
||||
echo "" >&2
|
||||
echo "若 API 失败:检查 api/.env 数据库是否可达;看 api/runtime/php-dev-server.log" >&2
|
||||
echo "若代理失败:确认 admin/.env.development 里 VITE_API_BASE_URL 为空;Vite 已起。" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed."
|
||||
exit 0
|
||||
101
scripts/dev_start.sh
Executable file
101
scripts/dev_start.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# mbti王 本地一键启动(前台):自动找 PHP>=8、释放 8787/5173、起 API + Vite;Ctrl+C 会停 PHP。
|
||||
# 需要「关终端仍运行」请用: bash scripts/dev_start_daemon.sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
API_PUBLIC="$ROOT/api/public"
|
||||
ADMIN="$ROOT/admin"
|
||||
LOG_DIR="$ROOT/api/runtime"
|
||||
LOG_FILE="$LOG_DIR/php-dev-server.log"
|
||||
API_PORT="${MBTI_API_PORT:-8787}"
|
||||
ADMIN_PORT="${MBTI_ADMIN_PORT:-5173}"
|
||||
|
||||
die() { echo "❌ $*" >&2; exit 1; }
|
||||
|
||||
php_ok() {
|
||||
local bin="$1"
|
||||
[[ -x "$bin" ]] || [[ -f "$bin" && -r "$bin" ]] || return 1
|
||||
"$bin" -r 'exit(version_compare(PHP_VERSION, "8.0.0", ">=") ? 0 : 1);' 2>/dev/null
|
||||
}
|
||||
|
||||
PHP_BIN=""
|
||||
for c in \
|
||||
"${MBTI_PHP_BIN:-}" \
|
||||
"/opt/homebrew/bin/php" \
|
||||
"/opt/homebrew/opt/php@8.4/bin/php" \
|
||||
"/opt/homebrew/opt/php@8.3/bin/php" \
|
||||
"/opt/homebrew/opt/php/bin/php" \
|
||||
"/usr/local/opt/php@8.4/bin/php" \
|
||||
"/usr/local/opt/php@8.3/bin/php" \
|
||||
"/usr/local/opt/php/bin/php" \
|
||||
"php"
|
||||
do
|
||||
[[ -z "$c" ]] && continue
|
||||
if command -v "$c" >/dev/null 2>&1 && php_ok "$(command -v "$c")"; then
|
||||
PHP_BIN="$(command -v "$c")"
|
||||
break
|
||||
fi
|
||||
if [[ -x "$c" ]] && php_ok "$c"; then
|
||||
PHP_BIN="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -n "$PHP_BIN" ]] || die "未找到 PHP 8.0+。请安装:brew install php@8.4,或 export MBTI_PHP_BIN=/你的/php"
|
||||
|
||||
[[ -f "$API_PUBLIC/router.php" ]] || die "缺少 $API_PUBLIC/router.php"
|
||||
[[ -f "$API_PUBLIC/index.php" ]] || die "缺少 $API_PUBLIC/index.php"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
free_port() {
|
||||
local port="$1"
|
||||
local pids
|
||||
pids="$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${pids:-}" ]]; then
|
||||
echo "WARN: port $port in use, killing: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 ${pids} 2>/dev/null || true
|
||||
sleep 0.4
|
||||
fi
|
||||
}
|
||||
|
||||
free_port "$API_PORT"
|
||||
free_port "$ADMIN_PORT"
|
||||
|
||||
PHP_VER="$("$PHP_BIN" -r 'echo PHP_VERSION;')"
|
||||
echo "OK PHP: $PHP_BIN ($PHP_VER)"
|
||||
echo "OK API: http://127.0.0.1:${API_PORT} log=${LOG_FILE}"
|
||||
|
||||
cd "$API_PUBLIC"
|
||||
: >"${LOG_FILE}"
|
||||
"$PHP_BIN" -S "127.0.0.1:${API_PORT}" router.php >>"${LOG_FILE}" 2>&1 &
|
||||
PHP_PID=$!
|
||||
|
||||
cleanup() {
|
||||
if kill -0 "$PHP_PID" 2>/dev/null; then
|
||||
echo ""
|
||||
echo "🛑 已停止 PHP (pid $PHP_PID)"
|
||||
kill "$PHP_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
sleep 0.2
|
||||
if ! kill -0 "$PHP_PID" 2>/dev/null; then
|
||||
echo "❌ PHP 内置服务器未能启动,请查看日志:" >&2
|
||||
tail -n 30 "$LOG_FILE" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK Admin: http://127.0.0.1:${ADMIN_PORT} (proxy -> API)"
|
||||
cd "$ADMIN"
|
||||
if [[ ! -d node_modules ]]; then
|
||||
echo "📦 首次运行,正在 npm install …"
|
||||
npm install
|
||||
fi
|
||||
|
||||
export VITE_DEV_API_PROXY="http://127.0.0.1:${API_PORT}"
|
||||
export MBTI_ADMIN_PORT="$ADMIN_PORT"
|
||||
exec npm run dev -- --port "$ADMIN_PORT" --host 0.0.0.0
|
||||
142
scripts/dev_start_daemon.sh
Normal file
142
scripts/dev_start_daemon.sh
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# mbti王 本地常驻启动:PHP API + Vite 后台运行,关闭终端/Cursor 后仍可用(用 dev_stop.sh 停止)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
API_PUBLIC="$ROOT/api/public"
|
||||
ADMIN="$ROOT/admin"
|
||||
LOG_DIR="$ROOT/api/runtime"
|
||||
PHP_LOG="$LOG_DIR/php-dev-server.log"
|
||||
VITE_LOG="$LOG_DIR/vite-dev-server.log"
|
||||
PHP_PID_FILE="$LOG_DIR/dev-php.pid"
|
||||
VITE_PID_FILE="$LOG_DIR/dev-vite.pid"
|
||||
API_PORT="${MBTI_API_PORT:-8787}"
|
||||
ADMIN_PORT="${MBTI_ADMIN_PORT:-5173}"
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
php_ok() {
|
||||
local bin="$1"
|
||||
[[ -x "$bin" ]] || [[ -f "$bin" && -r "$bin" ]] || return 1
|
||||
"$bin" -r 'exit(version_compare(PHP_VERSION, "8.0.0", ">=") ? 0 : 1);' 2>/dev/null
|
||||
}
|
||||
|
||||
PHP_BIN=""
|
||||
for c in \
|
||||
"${MBTI_PHP_BIN:-}" \
|
||||
"/opt/homebrew/bin/php" \
|
||||
"/opt/homebrew/opt/php@8.4/bin/php" \
|
||||
"/opt/homebrew/opt/php@8.3/bin/php" \
|
||||
"/opt/homebrew/opt/php/bin/php" \
|
||||
"/usr/local/opt/php@8.4/bin/php" \
|
||||
"/usr/local/opt/php@8.3/bin/php" \
|
||||
"/usr/local/opt/php/bin/php" \
|
||||
"php"
|
||||
do
|
||||
[[ -z "$c" ]] && continue
|
||||
if command -v "$c" >/dev/null 2>&1 && php_ok "$(command -v "$c")"; then
|
||||
PHP_BIN="$(command -v "$c")"
|
||||
break
|
||||
fi
|
||||
if [[ -x "$c" ]] && php_ok "$c"; then
|
||||
PHP_BIN="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -n "$PHP_BIN" ]] || die "Need PHP 8.0+. Install: brew install php@8.4 OR export MBTI_PHP_BIN=/path/to/php"
|
||||
[[ -f "$API_PUBLIC/router.php" ]] || die "Missing router.php"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
free_port() {
|
||||
local port="$1"
|
||||
local pids
|
||||
pids="$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${pids:-}" ]]; then
|
||||
echo "WARN: freeing port $port -> $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 ${pids} 2>/dev/null || true
|
||||
sleep 0.4
|
||||
fi
|
||||
}
|
||||
|
||||
free_port "$API_PORT"
|
||||
free_port "$ADMIN_PORT"
|
||||
rm -f "$PHP_PID_FILE" "$VITE_PID_FILE"
|
||||
|
||||
echo "Starting PHP API on 127.0.0.1:${API_PORT} ..."
|
||||
: >"$PHP_LOG"
|
||||
cd "$API_PUBLIC"
|
||||
nohup "$PHP_BIN" -S "127.0.0.1:${API_PORT}" router.php >>"$PHP_LOG" 2>&1 </dev/null &
|
||||
PHP_PID=$!
|
||||
echo "$PHP_PID" >"$PHP_PID_FILE"
|
||||
sleep 0.35
|
||||
kill -0 "$PHP_PID" 2>/dev/null || {
|
||||
echo "PHP failed. Last lines of $PHP_LOG:" >&2
|
||||
tail -n 40 "$PHP_LOG" >&2 || true
|
||||
die "PHP built-in server exited"
|
||||
}
|
||||
|
||||
api_curl_sec="${MBTI_API_CURL_TIMEOUT:-120}"
|
||||
echo "Waiting for API /api/config/runtime (curl max ${api_curl_sec}s, cloud DB may be slow) ..."
|
||||
api_ok=0
|
||||
code=""
|
||||
for _ in $(seq 1 30); do
|
||||
code="$(curl -sS -m "$api_curl_sec" -o /dev/null -w "%{http_code}" "http://127.0.0.1:${API_PORT}/api/config/runtime" 2>/dev/null || echo 000)"
|
||||
if [[ "$code" == "200" ]]; then api_ok=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$api_ok" != "1" ]]; then
|
||||
kill "$PHP_PID" 2>/dev/null || true
|
||||
rm -f "$PHP_PID_FILE"
|
||||
echo "Last HTTP code: ${code:-unknown}. Tail $PHP_LOG:" >&2
|
||||
tail -n 50 "$PHP_LOG" >&2 || true
|
||||
die "API not HTTP 200. Check api/.env DB reachable / firewall. See $PHP_LOG"
|
||||
fi
|
||||
|
||||
if [[ ! -d "$ADMIN/node_modules" ]]; then
|
||||
echo "Running npm install in admin/ ..."
|
||||
(cd "$ADMIN" && npm install)
|
||||
fi
|
||||
|
||||
echo "Starting Vite on 0.0.0.0:${ADMIN_PORT} (proxy -> API) ..."
|
||||
: >"$VITE_LOG"
|
||||
cd "$ADMIN"
|
||||
export VITE_DEV_API_PROXY="http://127.0.0.1:${API_PORT}"
|
||||
export MBTI_ADMIN_PORT="$ADMIN_PORT"
|
||||
nohup npx vite --host 0.0.0.0 --port "$ADMIN_PORT" >>"$VITE_LOG" 2>&1 </dev/null &
|
||||
VITE_PID=$!
|
||||
echo "$VITE_PID" >"$VITE_PID_FILE"
|
||||
sleep 0.6
|
||||
kill -0 "$VITE_PID" 2>/dev/null || {
|
||||
echo "Vite failed. Last lines of $VITE_LOG:" >&2
|
||||
tail -n 60 "$VITE_LOG" >&2 || true
|
||||
kill "$PHP_PID" 2>/dev/null || true
|
||||
die "Vite exited"
|
||||
}
|
||||
|
||||
echo "Waiting for Vite ..."
|
||||
vite_ok=0
|
||||
for _ in $(seq 1 60); do
|
||||
code="$(curl -sS -m 5 -o /dev/null -w "%{http_code}" "http://127.0.0.1:${ADMIN_PORT}/" 2>/dev/null || echo 000)"
|
||||
if [[ "$code" == "200" ]]; then vite_ok=1; break; fi
|
||||
sleep 0.35
|
||||
done
|
||||
if [[ "$vite_ok" != "1" ]]; then
|
||||
echo "Vite not ready. Last lines of $VITE_LOG:" >&2
|
||||
tail -n 60 "$VITE_LOG" >&2 || true
|
||||
kill "$PHP_PID" 2>/dev/null || true
|
||||
rm -f "$PHP_PID_FILE" "$VITE_PID_FILE"
|
||||
die "Vite not HTTP 200. See $VITE_LOG"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== mbti王 后台已就绪(关终端仍运行)==="
|
||||
echo " Admin: http://127.0.0.1:${ADMIN_PORT}/admin/login"
|
||||
echo " API: http://127.0.0.1:${API_PORT}/api/config/runtime"
|
||||
echo " Logs: $PHP_LOG"
|
||||
echo " $VITE_LOG"
|
||||
echo " Stop: bash scripts/dev_stop.sh"
|
||||
echo ""
|
||||
|
||||
bash "$ROOT/scripts/dev_health.sh" || true
|
||||
37
scripts/dev_stop.sh
Executable file
37
scripts/dev_stop.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# 结束本机 mbti王 开发进程(先按 pid 文件,再按端口)
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LOG_DIR="$ROOT/api/runtime"
|
||||
PHP_PID_FILE="$LOG_DIR/dev-php.pid"
|
||||
VITE_PID_FILE="$LOG_DIR/dev-vite.pid"
|
||||
API_PORT="${MBTI_API_PORT:-8787}"
|
||||
ADMIN_PORT="${MBTI_ADMIN_PORT:-5173}"
|
||||
|
||||
kill_pidfile() {
|
||||
local f="$1"
|
||||
[[ -f "$f" ]] || return 0
|
||||
local p
|
||||
p="$(tr -d ' \n\r\t' <"$f" || true)"
|
||||
[[ -n "${p:-}" ]] || { rm -f "$f"; return 0; }
|
||||
if kill -0 "$p" 2>/dev/null; then
|
||||
echo "停止 pid $p ($f)"
|
||||
kill -9 "$p" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$f"
|
||||
}
|
||||
|
||||
kill_pidfile "$PHP_PID_FILE"
|
||||
kill_pidfile "$VITE_PID_FILE"
|
||||
|
||||
for port in "$API_PORT" "$ADMIN_PORT"; do
|
||||
pids="$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${pids:-}" ]]; then
|
||||
echo "停止端口 $port: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 ${pids} 2>/dev/null || true
|
||||
else
|
||||
echo "端口 $port 无监听"
|
||||
fi
|
||||
done
|
||||
echo "完成"
|
||||
150
scripts/mysql_mirror_krimp_wechat_users.py
Normal file
150
scripts/mysql_mirror_krimp_wechat_users.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
将「源库」里 openid 以 krimp_ 开头的 wechat_users 行,INSERT IGNORE 到「目标库」。
|
||||
|
||||
用于:数据已写入仓库 api/.env 指向的腾讯云,但 mbtiapi 线上仍连宝塔本机 MySQL 时,
|
||||
把 krimp 导入用户同步到线上实际读写的库。
|
||||
|
||||
源/目标配置格式与 api/.env 相同(DATABASE_HOSTNAME、DATABASE_HOSTPORT、…)。
|
||||
|
||||
用法:
|
||||
# 复制 api/.env.mysql.line(内容=线上服务器 api/.env 里数据库段),勿提交 Git
|
||||
python3 scripts/mysql_mirror_krimp_wechat_users.py \\
|
||||
--source-env api/.env \\
|
||||
--target-env api/.env.mysql.line
|
||||
|
||||
仅校验连接、不写入:
|
||||
python3 scripts/mysql_mirror_krimp_wechat_users.py --target-env api/.env.mysql.line --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
sys.exit("需要 pymysql")
|
||||
|
||||
|
||||
def load_env(path: Path) -> dict[str, str]:
|
||||
cfg: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return cfg
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^([A-Z0-9_]+)\s*=\s*(.*)$", line)
|
||||
if m:
|
||||
cfg[m.group(1)] = m.group(2).strip()
|
||||
return cfg
|
||||
|
||||
|
||||
def connect(cfg: dict[str, str]):
|
||||
return pymysql.connect(
|
||||
host=cfg.get("DATABASE_HOSTNAME", "127.0.0.1"),
|
||||
port=int(cfg.get("DATABASE_HOSTPORT", "3306")),
|
||||
user=cfg.get("DATABASE_USERNAME", "root"),
|
||||
password=cfg.get("DATABASE_PASSWORD", ""),
|
||||
database=cfg.get("DATABASE_DATABASE", "mbti"),
|
||||
charset="utf8mb4",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--source-env", type=Path, default=root / "api" / ".env")
|
||||
ap.add_argument("--target-env", type=Path, required=True)
|
||||
ap.add_argument("--batch", type=int, default=200)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
src_cfg = load_env(args.source_env)
|
||||
dst_cfg = load_env(args.target_env)
|
||||
if not dst_cfg.get("DATABASE_HOSTNAME"):
|
||||
sys.exit(f"目标配置无效或文件不存在: {args.target_env}")
|
||||
|
||||
pre_s = src_cfg.get("DATABASE_PREFIX", "mbti_")
|
||||
pre_d = dst_cfg.get("DATABASE_PREFIX", "mbti_")
|
||||
tbl_s = f"{pre_s}wechat_users"
|
||||
tbl_d = f"{pre_d}wechat_users"
|
||||
|
||||
src = connect(src_cfg)
|
||||
dst = connect(dst_cfg)
|
||||
inserted = 0
|
||||
|
||||
try:
|
||||
with src.cursor() as cs, dst.cursor() as cd:
|
||||
cs.execute(f"SHOW COLUMNS FROM `{tbl_s}`")
|
||||
src_order = [r[0] for r in cs.fetchall()]
|
||||
cd.execute(f"SHOW COLUMNS FROM `{tbl_d}`")
|
||||
dst_set = {r[0] for r in cd.fetchall()}
|
||||
columns = [c for c in src_order if c != "id" and c in dst_set]
|
||||
col_sql = ", ".join(f"`{c}`" for c in columns)
|
||||
placeholders = ", ".join(["%s"] * len(columns))
|
||||
insert_sql = f"INSERT IGNORE INTO `{tbl_d}` ({col_sql}) VALUES ({placeholders})"
|
||||
|
||||
cs.execute(
|
||||
f"SELECT COUNT(*) FROM `{tbl_s}` WHERE `openid` LIKE %s", ("krimp_%",)
|
||||
)
|
||||
n_src = cs.fetchone()[0]
|
||||
|
||||
if args.dry_run:
|
||||
print(
|
||||
{
|
||||
"dry_run": True,
|
||||
"source_rows_krimp": n_src,
|
||||
"source_table": tbl_s,
|
||||
"target_table": tbl_d,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
with src.cursor() as cs, dst.cursor() as cd:
|
||||
cs.execute(
|
||||
f"SELECT {col_sql} FROM `{tbl_s}` WHERE `openid` LIKE %s",
|
||||
("krimp_%",),
|
||||
)
|
||||
batch = []
|
||||
for row in cs:
|
||||
batch.append(row)
|
||||
if len(batch) >= args.batch:
|
||||
cd.executemany(insert_sql, batch)
|
||||
inserted += cd.rowcount
|
||||
dst.commit()
|
||||
batch = []
|
||||
if batch:
|
||||
cd.executemany(insert_sql, batch)
|
||||
inserted += cd.rowcount
|
||||
dst.commit()
|
||||
|
||||
with dst.cursor() as cd:
|
||||
cd.execute(
|
||||
f"SELECT COUNT(*) FROM `{tbl_d}` WHERE `openid` LIKE %s", ("krimp_%",)
|
||||
)
|
||||
n_dst = cd.fetchone()[0]
|
||||
cd.execute(f"SELECT COUNT(*) FROM `{tbl_d}`")
|
||||
total_dst = cd.fetchone()[0]
|
||||
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"source_krimp": n_src,
|
||||
"target_krimp_after": n_dst,
|
||||
"target_total_after": total_dst,
|
||||
"insert_ignore_rowcount_sum": inserted,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
src.close()
|
||||
dst.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
204
scripts/reshuffle_ent_test_labels_mysql.py
Normal file
204
scripts/reshuffle_ent_test_labels_mysql.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
按企业批量重写 test_results.resultData,使:
|
||||
- MBTI:16 型均匀随机(略加抖动,避免扎堆)
|
||||
- DISC:D型/I型/S型/C型 随机分布
|
||||
- PDP:仅用中文(老虎型、孔雀型、考拉型、猫头鹰型、变色龙型)
|
||||
- face:嵌套 mbti / disc.primary / pdp.primary 同步为上述口径(PDP 中文)
|
||||
|
||||
读 api/.env 的 DATABASE_*。
|
||||
|
||||
用法:
|
||||
python3 scripts/reshuffle_ent_test_labels_mysql.py --enterprise-id 5
|
||||
python3 scripts/reshuffle_ent_test_labels_mysql.py --enterprise-id 5 --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
sys.exit("需要: pip install pymysql")
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ENV_PATH = ROOT / "api" / ".env"
|
||||
|
||||
MBTI_TYPES = [
|
||||
"INTJ",
|
||||
"INTP",
|
||||
"ENTJ",
|
||||
"ENTP",
|
||||
"INFJ",
|
||||
"INFP",
|
||||
"ENFJ",
|
||||
"ENFP",
|
||||
"ISTJ",
|
||||
"ISFJ",
|
||||
"ESTJ",
|
||||
"ESFJ",
|
||||
"ISTP",
|
||||
"ISFP",
|
||||
"ESTP",
|
||||
"ESFP",
|
||||
]
|
||||
DISC_CN = ["D型", "I型", "S型", "C型"]
|
||||
PDP_CN = ["老虎型", "孔雀型", "考拉型", "猫头鹰型", "变色龙型"]
|
||||
|
||||
|
||||
def load_env(path: Path) -> dict[str, str]:
|
||||
cfg: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return cfg
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^([A-Z0-9_]+)\s*=\s*(.*)$", line)
|
||||
if m:
|
||||
cfg[m.group(1)] = m.group(2).strip()
|
||||
return cfg
|
||||
|
||||
|
||||
def stable_pick(seq: list[str], row_id: int, salt: str) -> str:
|
||||
h = hashlib.md5(f"{row_id}:{salt}".encode()).hexdigest()
|
||||
return seq[int(h[:8], 16) % len(seq)]
|
||||
|
||||
|
||||
def build_mbti(row_id: int) -> str:
|
||||
# 连续 id 也会因 salt 不同而打散
|
||||
t = stable_pick(MBTI_TYPES, row_id, "mbti")
|
||||
return json.dumps({"mbtiType": t, "type": t}, ensure_ascii=False)
|
||||
|
||||
|
||||
def build_disc(row_id: int) -> str:
|
||||
label = stable_pick(DISC_CN, row_id, "disc")
|
||||
letter = label[0] # D I S C
|
||||
return json.dumps(
|
||||
{"description": {"type": label}, "dominantType": letter},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def build_pdp(row_id: int) -> str:
|
||||
label = stable_pick(PDP_CN, row_id, "pdp")
|
||||
return json.dumps(
|
||||
{"description": {"type": label}, "dominantType": label},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def build_face(row_id: int) -> str:
|
||||
m = stable_pick(MBTI_TYPES, row_id, "fmbti")
|
||||
d_label = stable_pick(DISC_CN, row_id, "fdisc")
|
||||
d_letter = d_label[0]
|
||||
p_label = stable_pick(PDP_CN, row_id, "fpdp")
|
||||
return json.dumps(
|
||||
{
|
||||
"mbti": {"type": m, "title": "—"},
|
||||
"disc": {"primary": d_label, "secondary": ""},
|
||||
"pdp": {"primary": p_label, "secondary": ""},
|
||||
"overview": "画像补数(分布已打散)",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--enterprise-id", type=int, default=5)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
eid = args.enterprise_id
|
||||
|
||||
env = load_env(ENV_PATH)
|
||||
host = env.get("DATABASE_HOSTNAME", "127.0.0.1")
|
||||
port = int(env.get("DATABASE_HOSTPORT", "3306"))
|
||||
user = env.get("DATABASE_USERNAME", "root")
|
||||
password = env.get("DATABASE_PASSWORD", "")
|
||||
database = env.get("DATABASE_DATABASE", "mbti")
|
||||
prefix = env.get("DATABASE_PREFIX", "mbti_")
|
||||
tr = f"{prefix}test_results"
|
||||
|
||||
conn = pymysql.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
builders = {
|
||||
"mbti": build_mbti,
|
||||
"disc": build_disc,
|
||||
"pdp": build_pdp,
|
||||
"face": build_face,
|
||||
}
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT id, testType FROM `{tr}` WHERE enterpriseId=%s "
|
||||
f"AND testType IN ('mbti','disc','pdp','face') ORDER BY id ASC",
|
||||
(eid,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
from collections import Counter
|
||||
|
||||
by_type = Counter(str(tt) for _rid, tt in rows)
|
||||
|
||||
updates: list[tuple[str, int]] = []
|
||||
for rid, tt in rows:
|
||||
tt = (tt or "").lower()
|
||||
fn = builders.get(tt)
|
||||
if not fn:
|
||||
continue
|
||||
updates.append((fn(int(rid)), int(rid)))
|
||||
|
||||
if args.dry_run:
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"enterpriseId": eid,
|
||||
"would_update": len(updates),
|
||||
"by_type": dict(by_type),
|
||||
"note_pending": "待审核仍由接口写死为0,无法仅靠改库达到12%。",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
with conn.cursor() as cur:
|
||||
for payload, rid in updates:
|
||||
cur.execute(
|
||||
f"UPDATE `{tr}` SET resultData=%s, updatedAt=%s WHERE id=%s",
|
||||
(payload, int(time.time()), rid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"dry_run": False,
|
||||
"enterpriseId": eid,
|
||||
"updated_rows": len(updates),
|
||||
"note_pending": "待审核仍由接口写死为0,无法仅靠改库达到12%。",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
401
scripts/seed_ent5_orders_and_distribution_mysql.py
Normal file
401
scripts/seed_ent5_orders_and_distribution_mysql.py
Normal file
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
为指定企业补「订单运营 + 分销推广」种子数据,量级与 wechat_users 池大致匹配(取总用户约 1/12 订单条数作中间值)。
|
||||
|
||||
- orders:enterpriseId 对齐;orderNo 前缀 SEED5_ 可幂等跳过
|
||||
- 为已支付/已完成订单写入一条 test_results(带 orderId),供分销「产品佣金分布」按测评类型归类
|
||||
- distribution_agents + distribution_bindings(enterprise 维度)+ commission_records
|
||||
|
||||
读 api/.env 的 DATABASE_*。
|
||||
|
||||
用法:
|
||||
python3 scripts/seed_ent5_orders_and_distribution_mysql.py --enterprise-id 5
|
||||
python3 scripts/seed_ent5_orders_and_distribution_mysql.py --enterprise-id 5 --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
sys.exit("需要: pip install pymysql")
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ENV_PATH = ROOT / "api" / ".env"
|
||||
ORDER_PREFIX = "SEED5_"
|
||||
|
||||
|
||||
def load_env(path: Path) -> dict[str, str]:
|
||||
cfg: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return cfg
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^([A-Z0-9_]+)\s*=\s*(.*)$", line)
|
||||
if m:
|
||||
cfg[m.group(1)] = m.group(2).strip()
|
||||
return cfg
|
||||
|
||||
|
||||
def pick_type(i: int) -> str:
|
||||
r = (i * 7919) % 100
|
||||
if r < 55:
|
||||
return "face"
|
||||
if r < 72:
|
||||
return "mbti"
|
||||
if r < 86:
|
||||
return "disc"
|
||||
return "pdp"
|
||||
|
||||
|
||||
def product_title(pt: str) -> str:
|
||||
return {
|
||||
"face": "AI人脸性格分析完整报告",
|
||||
"mbti": "MBTI 职业性格测评",
|
||||
"disc": "DISC 行为风格测评",
|
||||
"pdp": "PDP 天赋特质测评",
|
||||
}.get(pt, "测评服务")
|
||||
|
||||
|
||||
def mini_result_json(pt: str) -> str:
|
||||
import json
|
||||
|
||||
if pt == "mbti":
|
||||
return json.dumps({"mbtiType": "INTJ", "type": "INTJ"}, ensure_ascii=False)
|
||||
if pt == "disc":
|
||||
return json.dumps({"description": {"type": "D型"}}, ensure_ascii=False)
|
||||
if pt == "pdp":
|
||||
return json.dumps({"description": {"type": "老虎型"}}, ensure_ascii=False)
|
||||
return json.dumps(
|
||||
{"overview": "订单关联占位", "mbti": {"type": "INTJ"}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--enterprise-id", type=int, default=5)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--inviters", type=int, default=80)
|
||||
ap.add_argument("--bindings", type=int, default=520)
|
||||
args = ap.parse_args()
|
||||
eid = args.enterprise_id
|
||||
|
||||
env = load_env(ENV_PATH)
|
||||
host = env.get("DATABASE_HOSTNAME", "127.0.0.1")
|
||||
port = int(env.get("DATABASE_HOSTPORT", "3306"))
|
||||
user = env.get("DATABASE_USERNAME", "root")
|
||||
password = env.get("DATABASE_PASSWORD", "")
|
||||
database = env.get("DATABASE_DATABASE", "mbti")
|
||||
prefix = env.get("DATABASE_PREFIX", "mbti_")
|
||||
orders_t = f"{prefix}orders"
|
||||
tr_t = f"{prefix}test_results"
|
||||
wu_t = f"{prefix}wechat_users"
|
||||
ag_t = f"{prefix}distribution_agents"
|
||||
bd_t = f"{prefix}distribution_bindings"
|
||||
cr_t = f"{prefix}commission_records"
|
||||
|
||||
conn = pymysql.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
)
|
||||
rnd = random.Random(20260330 + eid)
|
||||
now = int(time.time())
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM `{wu_t}` WHERE `enterpriseId`=%s",
|
||||
(eid,),
|
||||
)
|
||||
n_users = int(cur.fetchone()[0])
|
||||
cur.execute(
|
||||
f"SELECT `id`,`nickname` FROM `{wu_t}` WHERE `enterpriseId`=%s ORDER BY `id` ASC",
|
||||
(eid,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
user_ids = [int(r[0]) for r in rows]
|
||||
nick_map = {int(r[0]): (r[1] or "") for r in rows}
|
||||
|
||||
if not user_ids:
|
||||
print({"ok": False, "error": "no users"})
|
||||
return
|
||||
|
||||
target_orders = max(400, min(1400, n_users // 12))
|
||||
existing_seed = 0
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM `{orders_t}` WHERE `orderNo` LIKE %s",
|
||||
(ORDER_PREFIX + "%",),
|
||||
)
|
||||
existing_seed = int(cur.fetchone()[0])
|
||||
|
||||
if existing_seed >= target_orders * 0.9 and not args.dry_run:
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"skipped": True,
|
||||
"reason": "SEED5 orders already near target",
|
||||
"existing_seed_orders": existing_seed,
|
||||
"target_orders": target_orders,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
need_orders = max(0, target_orders - existing_seed)
|
||||
if args.dry_run:
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"n_users": n_users,
|
||||
"target_orders": target_orders,
|
||||
"existing_seed": existing_seed,
|
||||
"would_insert_orders": need_orders,
|
||||
"inviters": min(args.inviters, len(user_ids) // 4),
|
||||
"bindings_cap": min(args.bindings, len(user_ids) // 2),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
inviter_n = min(args.inviters, max(20, len(user_ids) // 6))
|
||||
inviters = user_ids[:inviter_n]
|
||||
pool_bind = user_ids[inviter_n + 50 : inviter_n + 50 + args.bindings]
|
||||
if len(pool_bind) < 100:
|
||||
pool_bind = user_ids[inviter_n + 10 :]
|
||||
|
||||
# 1) distribution_agents
|
||||
inviter_to_agent: dict[int, int] = {}
|
||||
with conn.cursor() as cur:
|
||||
for uid in inviters:
|
||||
cur.execute(f"SELECT id FROM `{ag_t}` WHERE userId=%s LIMIT 1", (uid,))
|
||||
ex = cur.fetchone()
|
||||
if ex:
|
||||
inviter_to_agent[uid] = int(ex[0])
|
||||
continue
|
||||
name = (nick_map.get(uid) or f"用户{uid}")[:100]
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO `{ag_t}`
|
||||
(`userId`,`agentName`,`contactPhone`,`contactEmail`,`totalOrders`,`totalCommission`,
|
||||
`availableCommission`,`status`,`createdAt`,`updatedAt`)
|
||||
VALUES (%s,%s,NULL,NULL,0,0,0,1,%s,%s)
|
||||
""",
|
||||
(uid, name, now, now),
|
||||
)
|
||||
inviter_to_agent[uid] = int(cur.lastrowid)
|
||||
conn.commit()
|
||||
|
||||
# 2) orders + linked test_results (paid/completed only)
|
||||
order_rows: list[tuple] = []
|
||||
expire_at = now + 86400 * 400
|
||||
ins_order = f"""
|
||||
INSERT INTO `{orders_t}`
|
||||
(`orderNo`,`userId`,`enterpriseId`,`productType`,`productTitle`,`amount`,`status`,
|
||||
`payMethod`,`payTime`,`createdAt`,`updatedAt`)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
"""
|
||||
ins_tr = f"""
|
||||
INSERT INTO `{tr_t}`
|
||||
(`userId`,`testType`,`resultData`,`requiresPayment`,`isPaid`,`paidAmount`,`paidAt`,
|
||||
`createdAt`,`updatedAt`,`enterpriseId`,`testScope`,`orderId`)
|
||||
VALUES (%s,%s,%s,0,1,%s,%s,%s,%s,%s,'enterprise',%s)
|
||||
"""
|
||||
|
||||
for k in range(need_orders):
|
||||
uid = user_ids[(k * 9973) % len(user_ids)]
|
||||
pt = pick_type(k)
|
||||
amt = rnd.choice([100, 150, 200, 299, 399, 101, 201])
|
||||
st = rnd.choices(
|
||||
["paid", "completed", "pending"],
|
||||
weights=[42, 28, 30],
|
||||
k=1,
|
||||
)[0]
|
||||
ts = now - rnd.randint(0, 86400 * 20)
|
||||
pay_t = ts if st in ("paid", "completed") else None
|
||||
h = hashlib.md5(f"{uid}{k}{ts}".encode()).hexdigest()[:10]
|
||||
ono = f"{ORDER_PREFIX}{ts}{uid}{k}{h}"[:48]
|
||||
order_rows.append(
|
||||
(ono, uid, eid, pt, product_title(pt), amt, st, "wechat", pay_t, ts, ts)
|
||||
)
|
||||
|
||||
oid_map: list[int] = []
|
||||
with conn.cursor() as cur:
|
||||
for row in order_rows:
|
||||
cur.execute(ins_order, row)
|
||||
oid_map.append(int(cur.lastrowid))
|
||||
oid = oid_map[-1]
|
||||
st = row[6]
|
||||
if st not in ("paid", "completed"):
|
||||
continue
|
||||
uid = row[1]
|
||||
pt = row[3]
|
||||
amt = row[5]
|
||||
ts = row[9]
|
||||
pay_t = row[8] or ts
|
||||
cur.execute(
|
||||
ins_tr,
|
||||
(
|
||||
uid,
|
||||
pt,
|
||||
mini_result_json(pt),
|
||||
amt,
|
||||
pay_t,
|
||||
ts,
|
||||
ts,
|
||||
eid,
|
||||
oid,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 3) bindings
|
||||
bindings_done = 0
|
||||
binding_ids: list[tuple[int, int, int]] = [] # id, inviter, invitee
|
||||
with conn.cursor() as cur:
|
||||
for i, invitee in enumerate(pool_bind[: args.bindings]):
|
||||
inv = inviters[i % len(inviters)]
|
||||
if invitee == inv:
|
||||
continue
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id FROM `{bd_t}`
|
||||
WHERE inviteeId=%s AND scope='enterprise' AND enterpriseId=%s
|
||||
""",
|
||||
(invitee, eid),
|
||||
)
|
||||
if cur.fetchone():
|
||||
continue
|
||||
tsb = now - rnd.randint(86400, 86400 * 60)
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO `{bd_t}`
|
||||
(`inviterId`,`inviteeId`,`scope`,`enterpriseId`,`expireAt`,`status`,
|
||||
`prevInviterId`,`overriddenAt`,`createdAt`,`updatedAt`)
|
||||
VALUES (%s,%s,'enterprise',%s,%s,'active',NULL,NULL,%s,%s)
|
||||
""",
|
||||
(inv, invitee, eid, expire_at, tsb, tsb),
|
||||
)
|
||||
bid = int(cur.lastrowid)
|
||||
binding_ids.append((bid, inv, invitee))
|
||||
bindings_done += 1
|
||||
conn.commit()
|
||||
|
||||
# 4) commission_records(与订单/绑定挂钩)
|
||||
ins_cr = f"""
|
||||
INSERT INTO `{cr_t}`
|
||||
(`agentId`,`orderId`,`commissionRate`,`commissionAmount`,`status`,`paidAt`,
|
||||
`createdAt`,`updatedAt`,`scope`,`enterpriseId`,`inviterId`,`inviteeId`,
|
||||
`bindingId`,`orderAmount`,`commissionFen`,`frozenAt`,`unfrozenAt`,
|
||||
`testResultId`,`commissionSource`)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'enterprise',%s,%s,%s,%s,%s,%s,NULL,NULL,NULL,%s)
|
||||
"""
|
||||
cr_n = 0
|
||||
with conn.cursor() as cur:
|
||||
for j, oid in enumerate(oid_map[: min(len(oid_map), 450)]):
|
||||
row = order_rows[j]
|
||||
if row[6] not in ("paid", "completed"):
|
||||
continue
|
||||
uid_buyer = row[1]
|
||||
inv = inviters[j % len(inviters)]
|
||||
if uid_buyer == inv:
|
||||
inv = inviters[(j + 1) % len(inviters)]
|
||||
agent_id = inviter_to_agent.get(inv)
|
||||
if not agent_id:
|
||||
continue
|
||||
bid_row = next((b for b in binding_ids if b[2] == uid_buyer), None)
|
||||
bid = bid_row[0] if bid_row else None
|
||||
oamt = row[5]
|
||||
cfen = max(10, min(500, int(oamt * rnd.uniform(0.08, 0.22))))
|
||||
st_f = rnd.choices(["paid", "frozen"], weights=[82, 18], k=1)[0]
|
||||
paid_at = now - rnd.randint(0, 86400 * 14) if st_f == "paid" else None
|
||||
tsr = paid_at or (now - rnd.randint(0, 86400 * 10))
|
||||
camt = round(cfen / 100, 2)
|
||||
cur.execute(
|
||||
ins_cr,
|
||||
(
|
||||
agent_id,
|
||||
oid,
|
||||
10.0,
|
||||
camt,
|
||||
st_f,
|
||||
paid_at,
|
||||
tsr,
|
||||
tsr,
|
||||
eid,
|
||||
inv,
|
||||
uid_buyer,
|
||||
bid,
|
||||
oamt,
|
||||
cfen,
|
||||
"payment",
|
||||
),
|
||||
)
|
||||
cr_n += 1
|
||||
|
||||
extra = min(120, len(binding_ids))
|
||||
for j in range(extra):
|
||||
bid, inv, invitee = binding_ids[j]
|
||||
agent_id = inviter_to_agent.get(inv)
|
||||
if not agent_id:
|
||||
continue
|
||||
cfen = rnd.randint(20, 180)
|
||||
st_f = "paid"
|
||||
tsr = now - rnd.randint(0, 86400 * 7)
|
||||
cur.execute(
|
||||
ins_cr,
|
||||
(
|
||||
agent_id,
|
||||
None,
|
||||
0.0,
|
||||
round(cfen / 100, 2),
|
||||
st_f,
|
||||
tsr,
|
||||
tsr,
|
||||
tsr,
|
||||
eid,
|
||||
inv,
|
||||
invitee,
|
||||
bid,
|
||||
0,
|
||||
cfen,
|
||||
"seed_invite",
|
||||
),
|
||||
)
|
||||
cr_n += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"enterpriseId": eid,
|
||||
"n_users": n_users,
|
||||
"inserted_orders": len(oid_map),
|
||||
"bindings_inserted": bindings_done,
|
||||
"commission_rows": cr_n,
|
||||
"note": "已支付订单各带 1 条 test_results(orderId),概览「已完成测试」会增加;分销饼图按订单关联测评类型归类。",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user