feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)

Made-with: Cursor
This commit is contained in:
卡若
2026-04-17 19:46:48 +08:00
parent cf835ea585
commit 7108f28280
239 changed files with 21061 additions and 2175 deletions

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

View File

@@ -0,0 +1,379 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
按企业维度补全 MySQL 数据,使管理端「概览」与「用户列表」与 wechat_users 总用户对齐。
- 用户列表依赖 user_profileenterprise + 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,
)
# faceDISC/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()

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

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# mbti王 本地一键启动(前台):自动找 PHP>=8、释放 8787/5173、起 API + ViteCtrl+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
View 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
View 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 "完成"

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

View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
按企业批量重写 test_results.resultData使
- MBTI16 型均匀随机(略加抖动,避免扎堆)
- DISCD型/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()

View File

@@ -0,0 +1,401 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
为指定企业补「订单运营 + 分销推广」种子数据,量级与 wechat_users 池大致匹配(取总用户约 1/12 订单条数作中间值)。
- ordersenterpriseId 对齐orderNo 前缀 SEED5_ 可幂等跳过
- 为已支付/已完成订单写入一条 test_results带 orderId供分销「产品佣金分布」按测评类型归类
- distribution_agents + distribution_bindingsenterprise 维度)+ 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()