feat(scope)+fix(db)+feat(security): 数据范围(项目级)生效、库列类型对齐、安全加固、状态位分层展示、能耗实时聚合

- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
  总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
  前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
  GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
  DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
  恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
  新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
  服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
  仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
  每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
This commit is contained in:
12451 2026-09-20 02:44:51 +08:00
parent db1264953a
commit ee92102f3c
148 changed files with 9929 additions and 858 deletions

View File

@ -0,0 +1,28 @@
### BASE=http://127.0.0.1:3000/api
--- GET /api/energy-stats/summary
{"code":0,"data":{"cabinet_count":3,"estimated_cost":0.0,"month_energy":0,"online_count":3,"today_energy":0},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/cabinets
{"code":"DATABASE_ERROR","message":"服务器内部错误"}
[HTTP 500]
--- GET /api/energy-stats/projects
{"code":0,"data":[{"cabinet_count":1,"month_energy":0,"name":"联调测试项目","project_id":1,"today_energy":0},{"cabinet_count":2,"month_energy":0,"name":"能耗验证临时项目","project_id":2,"today_energy":0}],"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=day&range=30
{"code":0,"data":{"granularity":"day","list":[]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=week&range=30
{"code":0,"data":{"granularity":"week","list":[]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=month&range=30
{"code":0,"data":{"granularity":"month","list":[]},"message":"ok"}
[HTTP 200]
=== 导出接口(异步任务) ===
{"code":0,"data":{"task_id":1},"message":"导出任务已创建"}
[HTTP 200]

View File

@ -0,0 +1,31 @@
"""确认 pms_dev 上 charge_records 的索引(含能耗报表新增的两条)。"""
import pymysql
conn = pymysql.connect(host="10.8.0.252", user="root",
password="Hbhyg731024@", database="pms_dev")
cur = conn.cursor()
cur.execute(
"SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols, "
"non_unique FROM information_schema.statistics "
"WHERE table_schema = 'pms_dev' AND table_name = 'charge_records' "
"GROUP BY index_name, non_unique ORDER BY index_name"
)
print("charge_records 索引:")
for name, cols, non_unique in cur.fetchall():
kind = "UNIQUE" if non_unique == 0 else "INDEX"
print(f" {kind:6} {name:40} ({cols})")
cur.execute(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema='pms_dev' AND table_name='charge_records' "
"AND index_name='idx_charge_records_cabinet_start_time'"
)
print("idx_charge_records_cabinet_start_time 存在:", cur.fetchone()[0] == 2, "(2 列)")
cur.execute(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema='pms_dev' AND table_name='charge_records' "
"AND index_name='idx_charge_records_start_time'"
)
print("idx_charge_records_start_time 存在:", cur.fetchone()[0] == 1, "(1 列)")
cur.close()
conn.close()

View File

@ -0,0 +1,39 @@
"""确认能耗报表"今日/本月"的时区口径。
背景:服务返回 today_energy=6000(= 2026-09-19 那天的数据),而本机 pymysql
连接下 CURDATE() 是 2026-09-20。差异来自 **sqlx 会把 MySQL 会话时区设成 +00:00**
(保证时间转换确定性),而普通客户端沿用服务器的默认会话时区。
"""
import pymysql
conn = pymysql.connect(host="10.8.0.252", user="root",
password="Hbhyg731024@", database="pms_dev")
cur = conn.cursor()
cur.execute("SELECT @@global.time_zone, @@session.time_zone, NOW(), CURDATE()")
print("默认会话时区 :", cur.fetchone())
cur.execute("SET time_zone = '+00:00'")
cur.execute("SELECT @@session.time_zone, NOW(), CURDATE()")
print("模拟 sqlx(+00:00) :", cur.fetchone())
TODAY = "cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY"
THIS_MONTH = (
"cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') "
"AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01')"
)
cur.execute(f"SELECT CAST(COALESCE(SUM(cr.energy),0) AS SIGNED) FROM charge_records cr WHERE {TODAY}")
print("UTC 会话下 today :", cur.fetchone()[0], "(服务实测返回 6000 ✓)" if cur.rowcount else "")
cur.execute(f"SELECT CAST(COALESCE(SUM(cr.energy),0) AS SIGNED) FROM charge_records cr WHERE {THIS_MONTH}")
print("UTC 会话下 month :", cur.fetchone()[0], "(服务实测返回 6990 ✓)")
# start_time 在两种会话时区下的呈现(TIMESTAMP 列按会话时区转换)
cur.execute("SET time_zone = '+08:00'")
cur.execute("SELECT id, cabinet_id, start_time FROM charge_records ORDER BY id LIMIT 3")
print("+08:00 会话读 start_time :", cur.fetchall())
cur.execute("SET time_zone = '+00:00'")
cur.execute("SELECT id, cabinet_id, start_time FROM charge_records ORDER BY id LIMIT 3")
print("+00:00 会话读 start_time :", cur.fetchall())
cur.close()
conn.close()

View File

@ -0,0 +1,22 @@
DELETE charge_records 7 行 (ids=[10, 11, 12, 13, 14, 15, 16])
DELETE compartments 3 行 (ids=[5, 6, 7])
DELETE cabin_boards 3 行 (ids=[3, 4, 5])
DELETE cabinets 3 行 (ids=[2, 3, 4])
DELETE projects 2 行 (ids=[2, 3])
DELETE organizations 1 行 (ids=[4])
DELETE download_tasks 4 行 (ids=[1, 2, 3, 4])
DELETE users 1 行 (ids=[7])
=== 清理后校验 ===
charge_records = 0
energy_stats = 0
cabinets = 1
projects = 1
organizations = 1
users = 0
compartments = 4
cabin_boards = 2
download_tasks = 0
charge_records 索引(应保留): (('compartment_id', 'compartment_id'), ('idx_charge_records_cabinet', 'cabinet_id'), ('idx_charge_records_cabinet_start_time', 'cabinet_id,start_time'), ('idx_charge_records_start_time', 'start_time'), ('PRIMARY', 'id'))
临时数据清理完成

View File

@ -0,0 +1,72 @@
"""清理临时联调数据:只删除本任务自己插入的行(按记录到的 ID),
并删除测试期间产生的 download_tasks 行;**保留**新增的两条索引(迁移产物)。
删除顺序按外键依赖:charge_records → compartments → cabin_boards → cabinets → projects → organizations
"""
import json
import os
import pymysql
HERE = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(HERE, "temp_ids.json"), encoding="utf-8") as fh:
main_ids = json.load(fh)
with open(os.path.join(HERE, "temp_ids_orgB.json"), encoding="utf-8") as fh:
orgb_ids = json.load(fh)
record_ids = main_ids["record_ids"] + [orgb_ids["record_id"]]
compartment_ids = main_ids["compartment_ids"] + [orgb_ids["compartment_id"]]
board_ids = main_ids["board_ids"] + [orgb_ids["board_id"]]
cabinet_ids = main_ids["cabinet_ids"] + [orgb_ids["cabinet_id"]]
project_ids = [main_ids["project_id"], orgb_ids["project_id"]]
org_ids = [orgb_ids["org_id"]]
user_ids = [main_ids["user_id"]]
task_ids = [1, 2, 3, 4] # 本次测试创建的下载任务
conn = pymysql.connect(host="10.8.0.252", user="root",
password="Hbhyg731024@", database="pms_dev")
cur = conn.cursor()
steps = [
("charge_records", "id", record_ids),
("compartments", "id", compartment_ids),
("cabin_boards", "id", board_ids),
("cabinets", "id", cabinet_ids),
("projects", "id", project_ids),
("organizations", "id", org_ids),
# download_tasks.user_id 外键指向 users.id ⇒ 必须**先**删 download_tasks 再删 users
("download_tasks", "id", task_ids),
("users", "id", user_ids),
]
for table, col, ids in steps:
if not ids:
continue
ph = ",".join(["%s"] * len(ids))
cur.execute(f"DELETE FROM {table} WHERE {col} IN ({ph})", ids)
print(f"DELETE {table:16} {cur.rowcount:2d} 行 (ids={ids})")
conn.commit()
print("\n=== 清理后校验 ===")
for table, sql in [
("charge_records", "SELECT COUNT(*) FROM charge_records"),
("energy_stats", "SELECT COUNT(*) FROM energy_stats"),
("cabinets", "SELECT COUNT(*) FROM cabinets"),
("projects", "SELECT COUNT(*) FROM projects"),
("organizations", "SELECT COUNT(*) FROM organizations"),
("users", "SELECT COUNT(*) FROM users"),
("compartments", "SELECT COUNT(*) FROM compartments"),
("cabin_boards", "SELECT COUNT(*) FROM cabin_boards"),
("download_tasks", "SELECT COUNT(*) FROM download_tasks"),
]:
cur.execute(sql)
print(f" {table:16} = {cur.fetchone()[0]}")
cur.execute(
"SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics "
"WHERE table_schema='pms_dev' AND table_name='charge_records' GROUP BY index_name ORDER BY index_name"
)
print(" charge_records 索引(应保留):", cur.fetchall())
cur.close()
conn.close()
print("\n临时数据清理完成")

View File

@ -0,0 +1,8 @@
sheet: 能耗统计 dims: A1:F7
('日期', '柜子ID', '柜子名称', '所属项目', '能耗(Wh)', '充电次数')
('2026-09-20', 1, '联调测试柜', '联调测试项目', 220, 1)
('2026-09-20', 2, '能耗验证临时柜A', '能耗验证临时项目', 330, 1)
('2026-09-20', 3, '能耗验证临时柜B', '能耗验证临时项目', 440, 1)
('2026-09-19', 1, '联调测试柜', '联调测试项目', 1000, 1)
('2026-09-19', 2, '能耗验证临时柜A', '能耗验证临时项目', 2000, 1)
('2026-09-19', 3, '能耗验证临时柜B', '能耗验证临时项目', 3000, 1)

View File

@ -0,0 +1,2 @@
SUPER=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjYzMTUsImlhdCI6MTc4OTgzOTkxNX0.36ocLLksl-sZ_PK8M1mI8M9i9NOq0OcYv_xOdz2iCQA
ORG1=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjYzMTYsImlhdCI6MTc4OTgzOTkxNn0.OpOgTeYh9UdjReUVLh698TN9OOjmuPMxbao8LBZHQDM

View File

@ -0,0 +1,8 @@
charge_records 索引:
INDEX compartment_id (compartment_id)
INDEX idx_charge_records_cabinet (cabinet_id)
INDEX idx_charge_records_cabinet_start_time (cabinet_id,start_time)
INDEX idx_charge_records_start_time (start_time)
UNIQUE PRIMARY (id)
idx_charge_records_cabinet_start_time 存在: True (2 列)
idx_charge_records_start_time 存在: True (1 列)

View File

@ -0,0 +1,28 @@
### BASE=http://127.0.0.1:3000/api
--- GET /api/energy-stats/summary
{"code":0,"data":{"cabinet_count":3,"estimated_cost":5.59,"month_energy":6990,"online_count":3,"today_energy":6000},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/cabinets
{"code":0,"data":[{"abstract_id":"99-00000002","cabinet_id":3,"month_energy":3440,"name":"能耗验证临时柜B","today_energy":3000},{"abstract_id":"99-00000001","cabinet_id":2,"month_energy":2330,"name":"能耗验证临时柜A","today_energy":2000},{"abstract_id":"","cabinet_id":1,"month_energy":1220,"name":"联调测试柜","today_energy":1000}],"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/projects
{"code":0,"data":[{"cabinet_count":2,"month_energy":5770,"name":"能耗验证临时项目","project_id":2,"today_energy":5000},{"cabinet_count":1,"month_energy":1220,"name":"联调测试项目","project_id":1,"today_energy":1000}],"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=day&range=30
{"code":0,"data":{"granularity":"day","list":[{"charge_count":3,"energy":6000,"period":"2026-09-19"},{"charge_count":3,"energy":990,"period":"2026-09-20"}]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=week&range=30
{"code":0,"data":{"granularity":"week","list":[{"charge_count":6,"energy":6990,"period":"2026-38"}]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=month&range=30
{"code":0,"data":{"granularity":"month","list":[{"charge_count":6,"energy":6990,"period":"2026-09"}]},"message":"ok"}
[HTTP 200]
=== 导出接口(异步任务) ===
{"code":0,"data":{"task_id":4},"message":"导出任务已创建"}
[HTTP 200]

View File

@ -0,0 +1,28 @@
### BASE=http://127.0.0.1:3000/api
--- GET /api/energy-stats/summary
{"code":0,"data":{"cabinet_count":4,"estimated_cost":11.81,"month_energy":14767,"online_count":4,"today_energy":13777},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/cabinets
{"code":0,"data":[{"abstract_id":"99-00000009","cabinet_id":4,"month_energy":7777,"name":"能耗验证临时柜-组织B","today_energy":7777},{"abstract_id":"99-00000002","cabinet_id":3,"month_energy":3440,"name":"能耗验证临时柜B","today_energy":3000},{"abstract_id":"99-00000001","cabinet_id":2,"month_energy":2330,"name":"能耗验证临时柜A","today_energy":2000},{"abstract_id":"","cabinet_id":1,"month_energy":1220,"name":"联调测试柜","today_energy":1000}],"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/projects
{"code":0,"data":[{"cabinet_count":1,"month_energy":7777,"name":"能耗验证临时项目B","project_id":3,"today_energy":7777},{"cabinet_count":2,"month_energy":5770,"name":"能耗验证临时项目","project_id":2,"today_energy":5000},{"cabinet_count":1,"month_energy":1220,"name":"联调测试项目","project_id":1,"today_energy":1000}],"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=day&range=30
{"code":0,"data":{"granularity":"day","list":[{"charge_count":4,"energy":13777,"period":"2026-09-19"},{"charge_count":3,"energy":990,"period":"2026-09-20"}]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=week&range=30
{"code":0,"data":{"granularity":"week","list":[{"charge_count":7,"energy":14767,"period":"2026-38"}]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=month&range=30
{"code":0,"data":{"granularity":"month","list":[{"charge_count":7,"energy":14767,"period":"2026-09"}]},"message":"ok"}
[HTTP 200]
=== 导出接口(异步任务) ===
{"code":0,"data":{"task_id":3},"message":"导出任务已创建"}
[HTTP 200]

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""组织隔离实测:切换临时用户的角色,用两个 JWT 打同一组接口。
- token A: users.id=7 role=2 organization_id=NULL → 总管理员,应看到全部
- token B: users.id=7 role=1 organization_id=1 → 组织1管理员,应看不到组织B的 7777
"""
import time
import pymysql
import jwt
SECRET = "32e8708743c42fe764aff1d40737a63803a46815a9501f4bcb50c41fe3d27108"
FACTS = "facts.txt"
def token_for(role, org_id):
conn = pymysql.connect(host="10.8.0.252", user="root",
password="Hbhyg731024@", database="pms_dev")
cur = conn.cursor()
cur.execute("UPDATE users SET role=%s, organization_id=%s WHERE id=7", (role, org_id))
conn.commit()
cur.close()
conn.close()
now = int(time.time())
return jwt.encode({"sub": 7, "phone": "19900000001",
"exp": now + 86400, "iat": now}, SECRET, algorithm="HS256")
if __name__ == "__main__":
with open(FACTS, "w", encoding="utf-8") as fh:
fh.write("SUPER=" + token_for(2, None) + "\n")
fh.write("ORG1=" + token_for(1, 1) + "\n")
print(open(FACTS, encoding="utf-8").read())

View File

@ -0,0 +1,58 @@
"""组织隔离的实测准备:再插一个「另一个组织」的数据(energy=7777),
用于验证 role_level=1 的组织管理员**看不到**它的数据。
落点故意与 2026-09-19 那批数据同一时刻(= 服务 UTC 会话下的"今天"),
这样超管"今日"= 6000+7777 = 13777,而组织 1 管理员必须仍是 6000。
"""
import json
import os
import pymysql
HERE = os.path.dirname(os.path.abspath(__file__))
conn = pymysql.connect(host="10.8.0.252", user="root",
password="Hbhyg731024@", database="pms_dev")
cur = conn.cursor()
created = {"org_id": None, "project_id": None, "cabinet_id": None,
"board_id": None, "compartment_id": None, "record_id": None}
cur.execute("INSERT INTO organizations (name) VALUES ('能耗验证临时组织B')")
created["org_id"] = cur.lastrowid
org_b = created["org_id"]
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s, %s)",
(org_b, "能耗验证临时项目B"))
created["project_id"] = cur.lastrowid
cur.execute(
"INSERT INTO cabinets (project_id, abstract_id, imei, name, status) "
"VALUES (%s, %s, %s, %s, %s)",
(created["project_id"], "99-00000009", "999000000000009", "能耗验证临时柜-组织B", 1),
)
created["cabinet_id"] = cur.lastrowid
cur.execute("INSERT INTO cabin_boards (cabinet_id, board_id, status) VALUES (%s, 1, 1)",
(created["cabinet_id"],))
created["board_id"] = cur.lastrowid
cur.execute("INSERT INTO compartments (cabin_board_id, channel_id, status) VALUES (%s, 1, 0)",
(created["board_id"],))
created["compartment_id"] = cur.lastrowid
# 与 2026-09-19 那批(服务"今天")同一时刻
cur.execute(
"INSERT INTO charge_records "
"(compartment_id, cabinet_id, start_time, end_time, energy, max_temperature, status) "
"VALUES (%s, %s, DATE_SUB(CURDATE(), INTERVAL 1 DAY) + INTERVAL 10 HOUR, "
"DATE_SUB(CURDATE(), INTERVAL 1 DAY) + INTERVAL 12 HOUR, 7777, 29815, 0)",
(created["compartment_id"], created["cabinet_id"]),
)
created["record_id"] = cur.lastrowid
conn.commit()
path = os.path.join(HERE, "temp_ids_orgB.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(created, fh, ensure_ascii=False, indent=2)
print(json.dumps(created, ensure_ascii=False))
cur.close()
conn.close()

View File

@ -0,0 +1,99 @@
"""临时联调数据:为能耗报表验证准备 pms_dev 数据,并打印清理所需的 ID。
产出:
- 1 个临时用户(role=2,总管理员)→ 用于自签 JWT
- 1 个临时项目(org=1)+ 2 个临时柜子(含板/仓体)→ 用于验证项目/柜分组
- 6 条 charge_records(3 柜 × 2 天)→ 用于验证分组与趋势
所有 ID 写入 temp_ids.json,供 cleanup_temp_data.py 精确删除。
"""
import json
import os
import pymysql
HERE = os.path.dirname(os.path.abspath(__file__))
DB = dict(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev")
conn = pymysql.connect(**DB)
cur = conn.cursor()
created = {
"user_id": None,
"project_id": None,
"cabinet_ids": [],
"board_ids": [],
"compartment_ids": [],
"record_ids": [],
}
# ---------------------------------------------------------------- 临时总管理员
cur.execute(
"INSERT INTO users (phone, name, password, role, organization_id, status) "
"VALUES (%s, %s, %s, %s, %s, %s)",
("19900000001", "能耗验证临时管理员", "x", 2, None, 1),
)
created["user_id"] = cur.lastrowid
# ------------------------------------------------- 临时项目(org=1,验证项目分组)
cur.execute(
"INSERT INTO projects (organization_id, name) VALUES (%s, %s)",
(1, "能耗验证临时项目"),
)
temp_project_id = cur.lastrowid
created["project_id"] = temp_project_id
# ------------------------------------- 临时柜子 ×2(挂临时项目;imei 15 位、abstract_id 唯一)
for idx, name in enumerate(["能耗验证临时柜A", "能耗验证临时柜B"], start=1):
cur.execute(
"INSERT INTO cabinets (project_id, abstract_id, imei, name, status) "
"VALUES (%s, %s, %s, %s, %s)",
(temp_project_id, f"99-0000000{idx}", f"99900000000000{idx}", name, 1),
)
cabinet_id = cur.lastrowid
created["cabinet_ids"].append(cabinet_id)
cur.execute(
"INSERT INTO cabin_boards (cabinet_id, board_id, status) VALUES (%s, %s, %s)",
(cabinet_id, 1, 1),
)
board_id = cur.lastrowid
created["board_ids"].append(board_id)
cur.execute(
"INSERT INTO compartments (cabin_board_id, channel_id, status) VALUES (%s, %s, %s)",
(board_id, 1, 0),
)
created["compartment_ids"].append(cur.lastrowid)
comp_a, comp_b = created["compartment_ids"]
cab_a, cab_b = created["cabinet_ids"]
# ----------------------------------------------------------- 6 条充电记录
# 3 个柜子(已有柜 1 + 临时柜 A/B)× 2 天(今天 / 昨天):
# 柜1 今天 220 Wh 昨天 1000 Wh (项目 1「联调测试项目」)
# 柜A 今天 330 Wh 昨天 2000 Wh (临时项目)
# 柜B 今天 440 Wh 昨天 3000 Wh (临时项目)
# 期望:今日合计 990 Wh;本月合计 990 + 6000 = 6990 Wh
plan = [
(1, 1, 220, "CURDATE()"),
(1, 1, 1000, "DATE_SUB(CURDATE(), INTERVAL 1 DAY)"),
(cab_a, comp_a, 330, "CURDATE()"),
(cab_a, comp_a, 2000, "DATE_SUB(CURDATE(), INTERVAL 1 DAY)"),
(cab_b, comp_b, 440, "CURDATE()"),
(cab_b, comp_b, 3000, "DATE_SUB(CURDATE(), INTERVAL 1 DAY)"),
]
for cabinet_id, compartment_id, energy, day_expr in plan:
cur.execute(
"INSERT INTO charge_records "
"(compartment_id, cabinet_id, start_time, end_time, energy, max_temperature, status) "
f"VALUES (%s, %s, {day_expr} + INTERVAL 10 HOUR, {day_expr} + INTERVAL 12 HOUR, %s, %s, %s)",
(compartment_id, cabinet_id, energy, 29815, 0),
)
created["record_ids"].append(cur.lastrowid)
conn.commit()
with open(os.path.join(HERE, "temp_ids.json"), "w", encoding="utf-8") as fh:
json.dump(created, fh, ensure_ascii=False, indent=2)
print(json.dumps(created, ensure_ascii=False, indent=2))
cur.close()
conn.close()

View File

@ -0,0 +1,158 @@
==============================================================================
### 0. charge_records 现有行(cabinet_id / start_time / energy)
SQL> SELECT cr.id, cr.cabinet_id, c.name, cr.start_time, cr.end_time, cr.energy FROM charge_records cr LEFT JOIN cabinets c ON cr.cabinet_id = c.id ORDER BY cr.start_time DESC, cr.cabinet_id
id | cabinet_id | name | start_time | end_time | energy
10 | 1 | 联调测试柜 | 2026-09-20 10:00:00 | 2026-09-20 12:00:00 | 220.00
12 | 2 | 能耗验证临时柜A | 2026-09-20 10:00:00 | 2026-09-20 12:00:00 | 330.00
14 | 3 | 能耗验证临时柜B | 2026-09-20 10:00:00 | 2026-09-20 12:00:00 | 440.00
11 | 1 | 联调测试柜 | 2026-09-19 10:00:00 | 2026-09-19 12:00:00 | 1000.00
13 | 2 | 能耗验证临时柜A | 2026-09-19 10:00:00 | 2026-09-19 12:00:00 | 2000.00
15 | 3 | 能耗验证临时柜B | 2026-09-19 10:00:00 | 2026-09-19 12:00:00 | 3000.00
=> 6 行
==============================================================================
### 1a. summary today(总管理员,无组织条件)
SQL> SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY
CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)
990
=> 1 行
==============================================================================
### 1b. summary month(总管理员)
SQL> SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr WHERE cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01')
CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)
6990
=> 1 行
==============================================================================
### 1c. summary today(组织 1 隔离)
SQL> SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = 1)
CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)
990
=> 1 行
==============================================================================
### 1d. summary today(不存在组织 999 ⇒ 必须为空集 0)
SQL> SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = 999)
CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)
0
=> 1 行
==============================================================================
### 1e. summary today(客户侧无组织 ⇒ AND 1=0)
SQL> SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY AND 1=0
CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)
0
=> 1 行
==============================================================================
### 2a. cabinets 排行(总管理员)
SQL> SELECT c.id, COALESCE(c.abstract_id, '') AS abstract_id, c.name, CAST(COALESCE(cr_today.today_total, 0) AS SIGNED) AS today_energy, CAST(COALESCE(cr_month.month_total, 0) AS SIGNED) AS month_energy FROM cabinets c LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS today_total FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY GROUP BY cr.cabinet_id ) cr_today ON c.id = cr_today.cabinet_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS month_total FROM charge_records cr WHERE cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01') GROUP BY cr.cabinet_id ) cr_month ON c.id = cr_month.cabinet_id ORDER BY today_energy DESC, c.id ASC
id | abstract_id | name | today_energy | month_energy
3 | 99-00000002 | 能耗验证临时柜B | 440 | 3440
2 | 99-00000001 | 能耗验证临时柜A | 330 | 2330
1 | | 联调测试柜 | 220 | 1220
=> 3 行
==============================================================================
### 2b. cabinets 排行(组织 1 隔离)
SQL> SELECT c.id, COALESCE(c.abstract_id, '') AS abstract_id, c.name, CAST(COALESCE(cr_today.today_total, 0) AS SIGNED) AS today_energy, CAST(COALESCE(cr_month.month_total, 0) AS SIGNED) AS month_energy FROM cabinets c LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS today_total FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY GROUP BY cr.cabinet_id ) cr_today ON c.id = cr_today.cabinet_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS month_total FROM charge_records cr WHERE cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01') GROUP BY cr.cabinet_id ) cr_month ON c.id = cr_month.cabinet_id JOIN projects p ON c.project_id = p.id AND p.organization_id = 1 ORDER BY today_energy DESC, c.id ASC
id | abstract_id | name | today_energy | month_energy
3 | 99-00000002 | 能耗验证临时柜B | 440 | 3440
2 | 99-00000001 | 能耗验证临时柜A | 330 | 2330
1 | | 联调测试柜 | 220 | 1220
=> 3 行
==============================================================================
### 2c. cabinets 排行(客户侧无组织 ⇒ JOIN projects p ON 1=0 ⇒ 空)
SQL> SELECT c.id, COALESCE(c.abstract_id, '') AS abstract_id, c.name, CAST(COALESCE(cr_today.today_total, 0) AS SIGNED) AS today_energy, CAST(COALESCE(cr_month.month_total, 0) AS SIGNED) AS month_energy FROM cabinets c LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS today_total FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY GROUP BY cr.cabinet_id ) cr_today ON c.id = cr_today.cabinet_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS month_total FROM charge_records cr WHERE cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01') GROUP BY cr.cabinet_id ) cr_month ON c.id = cr_month.cabinet_id JOIN projects p ON 1=0 ORDER BY today_energy DESC, c.id ASC
id | abstract_id | name | today_energy | month_energy
=> 0 行
==============================================================================
### 3a. projects 统计(总管理员)
SQL> SELECT p.id, p.name, COUNT(DISTINCT c.id) AS cabinet_count, CAST(COALESCE(SUM(cr_today.today_total), 0) AS SIGNED) AS today_energy, CAST(COALESCE(SUM(cr_month.month_total), 0) AS SIGNED) AS month_energy FROM projects p LEFT JOIN cabinets c ON p.id = c.project_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS today_total FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY GROUP BY cr.cabinet_id ) cr_today ON c.id = cr_today.cabinet_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS month_total FROM charge_records cr WHERE cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01') GROUP BY cr.cabinet_id ) cr_month ON c.id = cr_month.cabinet_id GROUP BY p.id, p.name ORDER BY today_energy DESC, p.id ASC
id | name | cabinet_count | today_energy | month_energy
2 | 能耗验证临时项目 | 2 | 770 | 5770
1 | 联调测试项目 | 1 | 220 | 1220
=> 2 行
==============================================================================
### 3b. projects 统计(组织 1 隔离)
SQL> SELECT p.id, p.name, COUNT(DISTINCT c.id) AS cabinet_count, CAST(COALESCE(SUM(cr_today.today_total), 0) AS SIGNED) AS today_energy, CAST(COALESCE(SUM(cr_month.month_total), 0) AS SIGNED) AS month_energy FROM projects p LEFT JOIN cabinets c ON p.id = c.project_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS today_total FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY GROUP BY cr.cabinet_id ) cr_today ON c.id = cr_today.cabinet_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS month_total FROM charge_records cr WHERE cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01') GROUP BY cr.cabinet_id ) cr_month ON c.id = cr_month.cabinet_id WHERE p.organization_id = 1 GROUP BY p.id, p.name ORDER BY today_energy DESC, p.id ASC
id | name | cabinet_count | today_energy | month_energy
2 | 能耗验证临时项目 | 2 | 770 | 5770
1 | 联调测试项目 | 1 | 220 | 1220
=> 2 行
==============================================================================
### 3c. projects 统计(客户侧无组织 ⇒ WHERE 1=0)
SQL> SELECT p.id, p.name, COUNT(DISTINCT c.id) AS cabinet_count, CAST(COALESCE(SUM(cr_today.today_total), 0) AS SIGNED) AS today_energy, CAST(COALESCE(SUM(cr_month.month_total), 0) AS SIGNED) AS month_energy FROM projects p LEFT JOIN cabinets c ON p.id = c.project_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS today_total FROM charge_records cr WHERE cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY GROUP BY cr.cabinet_id ) cr_today ON c.id = cr_today.cabinet_id LEFT JOIN ( SELECT cr.cabinet_id, SUM(cr.energy) AS month_total FROM charge_records cr WHERE cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01') GROUP BY cr.cabinet_id ) cr_month ON c.id = cr_month.cabinet_id WHERE 1=0 GROUP BY p.id, p.name ORDER BY today_energy DESC, p.id ASC
id | name | cabinet_count | today_energy | month_energy
=> 0 行
==============================================================================
### 4a. trend granularity=day(总管理员, range=30)
SQL> SELECT DATE_FORMAT(cr.start_time, '%Y-%m-%d') AS period, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY period ORDER BY period ASC
period | energy | charge_count
2026-09-19 | 6000 | 3
2026-09-20 | 990 | 3
=> 2 行
==============================================================================
### 4b. trend granularity=week(range=30)
SQL> SELECT DATE_FORMAT(cr.start_time, '%Y-%u') AS period, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY period ORDER BY period ASC
period | energy | charge_count
2026-38 | 6990 | 6
=> 1 行
==============================================================================
### 4c. trend granularity=month(range=30)
SQL> SELECT DATE_FORMAT(cr.start_time, '%Y-%m') AS period, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY period ORDER BY period ASC
period | energy | charge_count
2026-09 | 6990 | 6
=> 1 行
==============================================================================
### 4d. trend granularity=day(组织 1 隔离)
SQL> SELECT DATE_FORMAT(cr.start_time, '%Y-%m-%d') AS period, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = 1) GROUP BY period ORDER BY period ASC
period | energy | charge_count
2026-09-19 | 6000 | 3
2026-09-20 | 990 | 3
=> 2 行
==============================================================================
### 4e. trend range=-5 **未经夹紧**(DATE_SUB 变成未来 5 天 ⇒ 必然空)
SQL> SELECT DATE_FORMAT(cr.start_time, '%Y-%m-%d') AS period, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL -5 DAY) GROUP BY period ORDER BY period ASC
period | energy | charge_count
=> 0 行
==============================================================================
### 4f. trend range=-5 经 clamp_range ⇒ 1(应含昨天+今天)
SQL> SELECT DATE_FORMAT(cr.start_time, '%Y-%m-%d') AS period, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY) GROUP BY period ORDER BY period ASC
period | energy | charge_count
2026-09-19 | 6000 | 3
2026-09-20 | 990 | 3
=> 2 行
==============================================================================
### 5a. export 明细(无筛选)
SQL> SELECT DATE(cr.start_time) AS stat_date, cr.cabinet_id, c.name AS cabinet_name, p.name AS project_name, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS total_energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr LEFT JOIN cabinets c ON cr.cabinet_id = c.id LEFT JOIN projects p ON c.project_id = p.id WHERE cr.start_time IS NOT NULL GROUP BY DATE(cr.start_time), cr.cabinet_id, c.name, p.name ORDER BY stat_date DESC, cr.cabinet_id ASC LIMIT 100000
stat_date | cabinet_id | cabinet_name | project_name | total_energy | charge_count
2026-09-20 | 1 | 联调测试柜 | 联调测试项目 | 220 | 1
2026-09-20 | 2 | 能耗验证临时柜A | 能耗验证临时项目 | 330 | 1
2026-09-20 | 3 | 能耗验证临时柜B | 能耗验证临时项目 | 440 | 1
2026-09-19 | 1 | 联调测试柜 | 联调测试项目 | 1000 | 1
2026-09-19 | 2 | 能耗验证临时柜A | 能耗验证临时项目 | 2000 | 1
2026-09-19 | 3 | 能耗验证临时柜B | 能耗验证临时项目 | 3000 | 1
=> 6 行
==============================================================================
### 5b. export 明细(end_date=今天必须含当天)
SQL> SELECT DATE(cr.start_time) AS stat_date, cr.cabinet_id, c.name AS cabinet_name, p.name AS project_name, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS total_energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr LEFT JOIN cabinets c ON cr.cabinet_id = c.id LEFT JOIN projects p ON c.project_id = p.id WHERE cr.start_time IS NOT NULL AND cr.start_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY) AND cr.start_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY) GROUP BY DATE(cr.start_time), cr.cabinet_id, c.name, p.name ORDER BY stat_date DESC, cr.cabinet_id ASC LIMIT 100000
stat_date | cabinet_id | cabinet_name | project_name | total_energy | charge_count
2026-09-20 | 1 | 联调测试柜 | 联调测试项目 | 220 | 1
2026-09-20 | 2 | 能耗验证临时柜A | 能耗验证临时项目 | 330 | 1
2026-09-20 | 3 | 能耗验证临时柜B | 能耗验证临时项目 | 440 | 1
2026-09-19 | 1 | 联调测试柜 | 联调测试项目 | 1000 | 1
2026-09-19 | 2 | 能耗验证临时柜A | 能耗验证临时项目 | 2000 | 1
2026-09-19 | 3 | 能耗验证临时柜B | 能耗验证临时项目 | 3000 | 1
=> 6 行
==============================================================================
### 5c. export 明细(组织 1 隔离)
SQL> SELECT DATE(cr.start_time) AS stat_date, cr.cabinet_id, c.name AS cabinet_name, p.name AS project_name, CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS total_energy, CAST(COUNT(*) AS SIGNED) AS charge_count FROM charge_records cr LEFT JOIN cabinets c ON cr.cabinet_id = c.id LEFT JOIN projects p ON c.project_id = p.id WHERE cr.start_time IS NOT NULL AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = 1) GROUP BY DATE(cr.start_time), cr.cabinet_id, c.name, p.name ORDER BY stat_date DESC, cr.cabinet_id ASC LIMIT 100000
stat_date | cabinet_id | cabinet_name | project_name | total_energy | charge_count
2026-09-20 | 1 | 联调测试柜 | 联调测试项目 | 220 | 1
2026-09-20 | 2 | 能耗验证临时柜A | 能耗验证临时项目 | 330 | 1
2026-09-20 | 3 | 能耗验证临时柜B | 能耗验证临时项目 | 440 | 1
2026-09-19 | 1 | 联调测试柜 | 联调测试项目 | 1000 | 1
2026-09-19 | 2 | 能耗验证临时柜A | 能耗验证临时项目 | 2000 | 1
2026-09-19 | 3 | 能耗验证临时柜B | 能耗验证临时项目 | 3000 | 1
=> 6 行
==============================================================================
### 9. 对照:energy_stats 表行数(已无人读写)
SQL> SELECT COUNT(*) AS energy_stats_rows FROM energy_stats
energy_stats_rows
0
=> 1 行

View File

@ -0,0 +1,24 @@
{
"user_id": 7,
"project_id": 2,
"cabinet_ids": [
2,
3
],
"board_ids": [
3,
4
],
"compartment_ids": [
5,
6
],
"record_ids": [
10,
11,
12,
13,
14,
15
]
}

View File

@ -0,0 +1,8 @@
{
"org_id": 4,
"project_id": 3,
"cabinet_id": 4,
"board_id": 5,
"compartment_id": 7,
"record_id": 16
}

View File

@ -0,0 +1,6 @@
默认会话时区 : ('+08:00', '+08:00', datetime.datetime(2026, 9, 20, 1, 44, 32), datetime.date(2026, 9, 20))
模拟 sqlx(+00:00) : ('+00:00', datetime.datetime(2026, 9, 19, 17, 44, 32), datetime.date(2026, 9, 19))
UTC 会话下 today : 6000 (服务实测返回 6000 ✓)
UTC 会话下 month : 6990 (服务实测返回 6990 ✓)
+08:00 会话读 start_time : ((10, 1, datetime.datetime(2026, 9, 20, 10, 0)), (11, 1, datetime.datetime(2026, 9, 19, 10, 0)), (12, 2, datetime.datetime(2026, 9, 20, 10, 0)))
+00:00 会话读 start_time : ((10, 1, datetime.datetime(2026, 9, 20, 2, 0)), (11, 1, datetime.datetime(2026, 9, 19, 2, 0)), (12, 2, datetime.datetime(2026, 9, 20, 2, 0)))

View File

@ -0,0 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5NDYsImlhdCI6MTc4OTgzOTU0Nn0.X-T6uBTENj9UK9seDIyyJJXopJcNJfSfDZ1C60AP-yA

View File

@ -0,0 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5ODksImlhdCI6MTc4OTgzOTU4OX0.ZiKHmR2fKvfSLaINjDq_R9npcFldGqP4WOQessAotxg

View File

@ -0,0 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5ODksImlhdCI6MTc4OTgzOTU4OX0.Y0Tju_TsC07S-qyIhD4cRpT8I1uqzTuCGWVH6QOorJM

View File

@ -0,0 +1,13 @@
#!/bin/bash
# 能耗报表接口验证脚本(在测试服务器上执行,直连本机 :3000,绕开 nginx)
# 用法: bash verify_endpoints.sh <JWT> [BASE]
TOKEN="$1"
BASE="${2:-http://127.0.0.1:3000/api}"
echo "### BASE=$BASE"
for path in "energy-stats/summary" "energy-stats/cabinets" "energy-stats/projects" "energy-stats/trend?granularity=day&range=30" "energy-stats/trend?granularity=week&range=30" "energy-stats/trend?granularity=month&range=30"; do
echo "--- GET /api/$path"
curl -s -m 20 -w "\n[HTTP %{http_code}]\n" -H "Authorization: Bearer $TOKEN" "$BASE/$path"
echo
done
echo "=== 导出接口(异步任务) ==="
curl -s -m 20 -w "\n[HTTP %{http_code}]\n" -H "Authorization: Bearer $TOKEN" "$BASE/energy-stats/export?start_date=2026-09-19&end_date=2026-09-20"

View File

@ -0,0 +1,69 @@
"""对**当前线上测试环境**(test.anzhizhichong.com → :3000,另一 agent 于 01:50 重新部署的
合并版二进制)做一次端到端验证:
插入 2 条临时 charge_records → 调 4 个接口(经 nginx + HTTPS)→ 删除临时数据。
服务端 sqlx 会话时区为 +00:00,所以"今天(UTC)"= 2026-09-19。
"""
import json
import time
import pymysql
import jwt
import requests
DB = dict(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev")
SECRET = "32e8708743c42fe764aff1d40737a63803a46815a9501f4bcb50c41fe3d27108"
BASE = "https://test.anzhizhichong.com/api/pms"
conn = pymysql.connect(**DB)
cur = conn.cursor()
# ---- 临时总管理员
cur.execute(
"INSERT INTO users (phone, name, password, role, organization_id, status) "
"VALUES ('19900000002','能耗复验临时管理员','x',2,NULL,1)"
)
user_id = cur.lastrowid
# ---- 2 条临时充电记录(挂在已有柜子 1 / 仓体 1 上):昨天 222Wh + 今天(UTC) 333Wh
records = []
for energy, day_expr in ((333, "CURDATE()"), (222, "DATE_SUB(CURDATE(), INTERVAL 1 DAY)")):
cur.execute(
"INSERT INTO charge_records "
"(compartment_id, cabinet_id, start_time, end_time, energy, max_temperature, status) "
f"VALUES (1, 1, {day_expr} + INTERVAL 10 HOUR, {day_expr} + INTERVAL 12 HOUR, %s, 29815, 0)",
(energy,),
)
records.append(cur.lastrowid)
conn.commit()
print(f"插入临时数据: user_id={user_id} charge_records={records} (333Wh + 222Wh)")
now = int(time.time())
token = jwt.encode({"sub": user_id, "phone": "19900000002",
"exp": now + 3600, "iat": now}, SECRET, algorithm="HS256")
headers = {"Authorization": f"Bearer {token}"}
paths = [
"/energy-stats/summary",
"/energy-stats/cabinets",
"/energy-stats/projects",
"/energy-stats/trend?granularity=day&range=30",
]
try:
for p in paths:
r = requests.get(BASE + p, headers=headers, timeout=20)
print(f"\n--- GET /api/pms{p} [HTTP {r.status_code}]")
try:
print(json.dumps(r.json(), ensure_ascii=False))
except ValueError:
print(r.text[:400])
finally:
# ---- 清理
cur.execute("DELETE FROM charge_records WHERE id IN (%s,%s)", records)
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
conn.commit()
print(f"\n已清理: charge_records {records} 与 user_id={user_id}")
cur.execute("SELECT COUNT(*) FROM charge_records")
print("charge_records 剩余行数 =", cur.fetchone()[0])
cur.close()
conn.close()

View File

@ -0,0 +1,16 @@
插入临时数据: user_id=9 charge_records=[17, 18] (333Wh + 222Wh)
--- GET /api/pms/energy-stats/summary [HTTP 200]
{"code": 0, "data": {"cabinet_count": 1, "estimated_cost": 0.44, "month_energy": 555, "online_count": 1, "today_energy": 222}, "message": "ok"}
--- GET /api/pms/energy-stats/cabinets [HTTP 200]
{"code": 0, "data": [{"abstract_id": "", "cabinet_id": 1, "month_energy": 555, "name": "TCP-E2E-SMOKE", "today_energy": 222}], "message": "ok"}
--- GET /api/pms/energy-stats/projects [HTTP 200]
{"code": 0, "data": [{"cabinet_count": 1, "month_energy": 555, "name": "联调测试项目", "project_id": 1, "today_energy": 222}], "message": "ok"}
--- GET /api/pms/energy-stats/trend?granularity=day&range=30 [HTTP 200]
{"code": 0, "data": {"granularity": "day", "list": [{"charge_count": 1, "energy": 222, "period": "2026-09-19"}, {"charge_count": 1, "energy": 333, "period": "2026-09-20"}]}, "message": "ok"}
已清理: charge_records [17, 18] 与 user_id=9
charge_records 剩余行数 = 0

View File

@ -0,0 +1,28 @@
### BASE=http://127.0.0.1:3000/api
--- GET /api/energy-stats/summary
{"code":0,"data":{"cabinet_count":3,"estimated_cost":5.59,"month_energy":6990,"online_count":3,"today_energy":6000},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/cabinets
{"code":0,"data":[{"abstract_id":"99-00000002","cabinet_id":3,"month_energy":3440,"name":"能耗验证临时柜B","today_energy":3000},{"abstract_id":"99-00000001","cabinet_id":2,"month_energy":2330,"name":"能耗验证临时柜A","today_energy":2000},{"abstract_id":"","cabinet_id":1,"month_energy":1220,"name":"联调测试柜","today_energy":1000}],"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/projects
{"code":0,"data":[{"cabinet_count":2,"month_energy":5770,"name":"能耗验证临时项目","project_id":2,"today_energy":5000},{"cabinet_count":1,"month_energy":1220,"name":"联调测试项目","project_id":1,"today_energy":1000}],"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=day&range=30
{"code":0,"data":{"granularity":"day","list":[{"charge_count":3,"energy":6000,"period":"2026-09-19"},{"charge_count":3,"energy":990,"period":"2026-09-20"}]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=week&range=30
{"code":0,"data":{"granularity":"week","list":[{"charge_count":6,"energy":6990,"period":"2026-38"}]},"message":"ok"}
[HTTP 200]
--- GET /api/energy-stats/trend?granularity=month&range=30
{"code":0,"data":{"granularity":"month","list":[{"charge_count":6,"energy":6990,"period":"2026-09"}]},"message":"ok"}
[HTTP 200]
=== 导出接口(异步任务) ===
{"code":0,"data":{"task_id":2},"message":"导出任务已创建"}
[HTTP 200]

View File

@ -0,0 +1,160 @@
"""用 pymysql 直接跑改后的 SQL(与 routes/energy_stats.rs 中的语句逐字对应,
唯一差别:Rust 用 `?` 占位符 + bind,这里把测试常量(org_id=1 / range=30)
直接内联,以保持 SQL 文本与 Rust 生成的完全一致,并避开 pymysql 的 `%` 转义)。
只做只读 SELECT,不改任何数据。
"""
import pymysql
DB = dict(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev")
conn = pymysql.connect(**DB)
cur = conn.cursor()
TODAY = "cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY"
THIS_MONTH = (
"cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') "
"AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01')"
)
SUM_E = "CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)"
CNT_E = "CAST(COUNT(*) AS SIGNED)"
# 组织隔离片段(与 auth::org_condition(user, "cr") / 各处的内联版本逐字一致)
ORG_WHERE_1 = (" AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p "
"ON c.project_id = p.id WHERE p.organization_id = 1)")
ORG_WHERE_999 = (" AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p "
"ON c.project_id = p.id WHERE p.organization_id = 999)")
ORG_WHERE_NONE = " AND 1=0"
ORG_JOIN_1 = " JOIN projects p ON c.project_id = p.id AND p.organization_id = 1"
ORG_JOIN_NONE = " JOIN projects p ON 1=0"
PROJ_ORG_1 = " WHERE p.organization_id = 1"
PROJ_ORG_NONE = " WHERE 1=0"
def show(title, sql):
print("=" * 78)
print(f"### {title}")
print("SQL> " + " ".join(sql.split()))
cur.execute(sql)
cols = [d[0] for d in cur.description]
print(" " + " | ".join(cols))
rows = cur.fetchall()
for r in rows:
print(" " + " | ".join(str(v) for v in r))
print(f" => {len(rows)} 行")
return rows
# ---------------------------------------------------------------- 原始数据
show(
"0. charge_records 现有行(cabinet_id / start_time / energy)",
"SELECT cr.id, cr.cabinet_id, c.name, cr.start_time, cr.end_time, cr.energy "
"FROM charge_records cr LEFT JOIN cabinets c ON cr.cabinet_id = c.id "
"ORDER BY cr.start_time DESC, cr.cabinet_id",
)
# ---------------------------------------------------------------- ① summary
show("1a. summary today(总管理员,无组织条件)",
f"SELECT {SUM_E} FROM charge_records cr WHERE {TODAY}")
show("1b. summary month(总管理员)",
f"SELECT {SUM_E} FROM charge_records cr WHERE {THIS_MONTH}")
show("1c. summary today(组织 1 隔离)",
f"SELECT {SUM_E} FROM charge_records cr WHERE {TODAY}{ORG_WHERE_1}")
show("1d. summary today(不存在组织 999 ⇒ 必须为空集 0)",
f"SELECT {SUM_E} FROM charge_records cr WHERE {TODAY}{ORG_WHERE_999}")
show("1e. summary today(客户侧无组织 ⇒ AND 1=0)",
f"SELECT {SUM_E} FROM charge_records cr WHERE {TODAY}{ORG_WHERE_NONE}")
# ---------------------------------------------------------------- ② cabinets
RANK = f"""SELECT c.id, COALESCE(c.abstract_id, '') AS abstract_id, c.name,
CAST(COALESCE(cr_today.today_total, 0) AS SIGNED) AS today_energy,
CAST(COALESCE(cr_month.month_total, 0) AS SIGNED) AS month_energy
FROM cabinets c
LEFT JOIN (
SELECT cr.cabinet_id, SUM(cr.energy) AS today_total
FROM charge_records cr
WHERE {TODAY}
GROUP BY cr.cabinet_id
) cr_today ON c.id = cr_today.cabinet_id
LEFT JOIN (
SELECT cr.cabinet_id, SUM(cr.energy) AS month_total
FROM charge_records cr
WHERE {THIS_MONTH}
GROUP BY cr.cabinet_id
) cr_month ON c.id = cr_month.cabinet_id
{{org}}
ORDER BY today_energy DESC, c.id ASC"""
show("2a. cabinets 排行(总管理员)", RANK.format(org=""))
show("2b. cabinets 排行(组织 1 隔离)", RANK.format(org=ORG_JOIN_1))
show("2c. cabinets 排行(客户侧无组织 ⇒ JOIN projects p ON 1=0 ⇒ 空)",
RANK.format(org=ORG_JOIN_NONE))
# ---------------------------------------------------------------- ③ projects
PROJ = f"""SELECT p.id, p.name,
COUNT(DISTINCT c.id) AS cabinet_count,
CAST(COALESCE(SUM(cr_today.today_total), 0) AS SIGNED) AS today_energy,
CAST(COALESCE(SUM(cr_month.month_total), 0) AS SIGNED) AS month_energy
FROM projects p
LEFT JOIN cabinets c ON p.id = c.project_id
LEFT JOIN (
SELECT cr.cabinet_id, SUM(cr.energy) AS today_total
FROM charge_records cr
WHERE {TODAY}
GROUP BY cr.cabinet_id
) cr_today ON c.id = cr_today.cabinet_id
LEFT JOIN (
SELECT cr.cabinet_id, SUM(cr.energy) AS month_total
FROM charge_records cr
WHERE {THIS_MONTH}
GROUP BY cr.cabinet_id
) cr_month ON c.id = cr_month.cabinet_id
{{org}}
GROUP BY p.id, p.name
ORDER BY today_energy DESC, p.id ASC"""
show("3a. projects 统计(总管理员)", PROJ.format(org=""))
show("3b. projects 统计(组织 1 隔离)", PROJ.format(org=PROJ_ORG_1))
show("3c. projects 统计(客户侧无组织 ⇒ WHERE 1=0)", PROJ.format(org=PROJ_ORG_NONE))
# ---------------------------------------------------------------- ④ trend
TREND = f"""SELECT {{period}} AS period, {SUM_E} AS energy, {CNT_E} AS charge_count
FROM charge_records cr
WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL {{rng}} DAY){{org}}
GROUP BY period ORDER BY period ASC"""
show("4a. trend granularity=day(总管理员, range=30)",
TREND.format(period="DATE_FORMAT(cr.start_time, '%Y-%m-%d')", rng=30, org=""))
show("4b. trend granularity=week(range=30)",
TREND.format(period="DATE_FORMAT(cr.start_time, '%Y-%u')", rng=30, org=""))
show("4c. trend granularity=month(range=30)",
TREND.format(period="DATE_FORMAT(cr.start_time, '%Y-%m')", rng=30, org=""))
show("4d. trend granularity=day(组织 1 隔离)",
TREND.format(period="DATE_FORMAT(cr.start_time, '%Y-%m-%d')", rng=30, org=ORG_WHERE_1))
show("4e. trend range=-5 **未经夹紧**(DATE_SUB 变成未来 5 天 ⇒ 必然空)",
TREND.format(period="DATE_FORMAT(cr.start_time, '%Y-%m-%d')", rng=-5, org=""))
show("4f. trend range=-5 经 clamp_range ⇒ 1(应含昨天+今天)",
TREND.format(period="DATE_FORMAT(cr.start_time, '%Y-%m-%d')", rng=1, org=""))
# ---------------------------------------------------------------- ⑤ export
EXPORT = f"""SELECT DATE(cr.start_time) AS stat_date, cr.cabinet_id, c.name AS cabinet_name,
p.name AS project_name,
CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) AS total_energy,
{CNT_E} AS charge_count
FROM charge_records cr
LEFT JOIN cabinets c ON cr.cabinet_id = c.id
LEFT JOIN projects p ON c.project_id = p.id
WHERE cr.start_time IS NOT NULL{{extra}}
GROUP BY DATE(cr.start_time), cr.cabinet_id, c.name, p.name
ORDER BY stat_date DESC, cr.cabinet_id ASC
LIMIT 100000"""
show("5a. export 明细(无筛选)", EXPORT.format(extra=""))
show("5b. export 明细(end_date=今天必须含当天)",
EXPORT.format(
extra=" AND cr.start_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY)"
" AND cr.start_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY)"
))
show("5c. export 明细(组织 1 隔离)", EXPORT.format(extra=ORG_WHERE_1))
# ---------------------------------------------------------------- 对照
show("9. 对照:energy_stats 表行数(已无人读写)",
"SELECT COUNT(*) AS energy_stats_rows FROM energy_stats")
cur.close()
conn.close()

Binary file not shown.

View File

@ -0,0 +1,2 @@
Checking pms-api v0.1.0 (D:\charging-cabinet\software\api-server)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.23s

View File

@ -0,0 +1 @@
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.80s

View File

@ -0,0 +1,151 @@
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.46s
Running unittests src\main.rs (.cargo-check-target\debug\deps\pms_api-0e8b7d95b05a137e.exe)
running 144 tests
test commands::tests::test_is_valid_command_target_accepts_board_and_compartment ... ok
test commands::tests::test_is_valid_command_target_rejects_imei_and_malformed ... ok
test commands::tests::test_take_command_target_rejects_invalid_id ... ok
test db::migrate::tests::test_alignment_targets_cover_known_broken_columns ... ok
test db::migrate::tests::test_every_alignment_target_appears_verbatim_in_schema_sql ... ok
test db::migrate::tests::test_column_spec_from_target_parses_all_dimensions ... ok
test commands::tests::test_take_command_target_absent_returns_none ... ok
test db::migrate::tests::test_referenced_tables_single_foreign_key_returns_table_name ... ok
test db::migrate::tests::test_schema_sql_energy_columns_comment_unit_is_wh ... ok
test db::migrate::tests::test_schema_sql_table_order_has_forward_reference_returns_none ... ok
test commands::tests::test_take_command_target_extracts_and_removes_id ... ok
test middleware::auth::tests::test_append_condition_handles_empty_clause ... ok
test middleware::auth::tests::test_builtin_role_name_covers_all_valid_levels ... ok
test middleware::auth::tests::test_builtin_role_name_invalid_level_returns_none ... ok
test middleware::auth::tests::test_builtin_role_name_operator_maps_to_normal_user ... ok
test db::migrate::tests::test_schema_sql_contains_no_timestamp_column ... ok
test middleware::auth::tests::test_builtin_role_name_org_admin_maps_to_org_admin ... ok
test middleware::auth::tests::test_builtin_role_name_super_admin_maps_to_super_admin ... ok
test middleware::auth::tests::test_check_permission_operator_empty_permissions_forbidden ... ok
test middleware::auth::tests::test_org_condition_no_org ... ok
test middleware::auth::tests::test_org_condition_normal_admin ... ok
test middleware::auth::tests::test_org_condition_super_admin ... ok
test middleware::auth::tests::test_super_admin_role_level_constant_matches_mapping ... ok
test middleware::auth::tests::test_user_scope_allows_project ... ok
test middleware::auth::tests::test_user_scope_default_is_unrestricted ... ok
test middleware::auth::tests::test_user_scope_project_condition_narrows_by_project ... ok
test middleware::auth::tests::test_verify_password_invalid_hash ... ok
test routes::cabinet_tree::tests::test_build_filter_where_scope_applies_to_explicit_project_and_org_params ... ok
test routes::cabinet_tree::tests::test_build_filter_where_scope_combines_with_bound_filter ... ok
test routes::cabinet_tree::tests::test_build_filter_where_super_admin_ignores_scope ... ok
test routes::cabinet_tree::tests::test_build_filter_where_with_scope_appends_project_narrowing ... ok
test routes::cabinet_tree::tests::test_build_filter_where_without_scope_is_unchanged ... ok
test routes::cabinet_tree::tests::test_channel_flags_has_data_and_status_code_match_frontend_mapping ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_all_idle_channels_yield_zero_full_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_charging_channels_counted_as_charging ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_fault_channels_counted_as_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_fault_wins_over_full_on_conflict ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_full_channels_counted_as_full ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_offline_missing_data_yields_zero_full_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_batt_fault_bits_map_to_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_charging_channel_sets_on_true ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_empty_channel_keeps_power_status ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_full_battery_prefers_full_over_charging ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_missing_fields_yields_idle_and_empty_hex ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_normalizes_hex_case_and_padding ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_pow_fault_maps_to_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_single_byte_yields_empty_hex ... ok
test routes::cabinet_tree::tests::test_device_access_sql_appends_scope_only_when_configured ... ok
test routes::cabinet_tree::tests::test_device_list_fields_in_sync ... ok
test routes::cabinet_tree::tests::test_parse_channel_key_accepts_real_channel_keys ... ok
test routes::cabinet_tree::tests::test_parse_channel_key_rejects_internal_keys ... ok
test routes::cabinet_tree::tests::test_parse_channel_key_rejects_out_of_range_and_malformed ... ok
test routes::cabinet_tree::tests::test_parse_hex_byte_rejects_malformed_values ... ok
test routes::cabinet_tree::tests::test_pick_non_empty_hex_keeps_00_but_drops_missing ... ok
test routes::cabinets::tests::test_apply_realtime_keeps_null_when_missing ... ok
test routes::cabinets::tests::test_cabinet_realtime_field_indices_are_in_sync ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_cabinet_status_bits_raw_hex ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_cabinet_status_missing_is_null_not_00 ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_missing_fields_stay_none ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_reads_voltage_current_and_temp ... ok
test routes::charge_orders::tests::test_order_scope_conditions_applies_scope ... ok
test routes::charge_orders::tests::test_order_scope_conditions_org_isolation ... ok
test routes::charge_orders::tests::test_order_scope_conditions_super_admin_exempt ... ok
test routes::charge_records::tests::test_build_export_where_scope_appends_to_existing_conditions ... ok
test routes::charge_records::tests::test_build_export_where_applies_scope_project_ids ... ok
test routes::energy_stats::tests::test_build_export_where_keeps_org_isolation ... ok
test routes::energy_stats::tests::test_clamp_range_bounds_illegal_values ... ok
test routes::energy_stats::tests::test_build_export_clause_targets_charge_records ... ok
test routes::energy_stats::tests::test_estimated_cost_unit_is_wh_times_80_cent_per_kwh ... ok
test routes::energy_stats::tests::test_build_export_clause_empty_when_no_filters ... ok
test routes::energy_stats::tests::test_time_window_uses_start_time_half_open_ranges ... ok
test routes::h5::tests::test_compartment_realtime_fields_order_matches_indexes ... ok
test routes::h5::tests::test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing ... ok
test routes::h5::tests::test_parse_compartment_realtime_garbage_is_none ... ok
test routes::h5::tests::test_parse_compartment_realtime_no_data_returns_none_not_zero ... ok
test routes::h5::tests::test_parse_compartment_realtime_reads_all_fields ... ok
test routes::h5::tests::test_parse_compartment_realtime_real_zero_is_kept ... ok
test routes::h5::tests::test_visible_org_scope_operator_sees_own_org_only ... ok
test routes::h5::tests::test_visible_org_scope_org_admin_sees_own_org_only ... ok
test routes::h5::tests::test_visible_org_scope_org_user_without_org_is_empty ... ok
test routes::h5::tests::test_visible_org_scope_super_admin_sees_all ... ok
test routes::h5::tests::test_visible_orgs_own_sql_filters_by_id ... ok
test routes::scope_guard::tests::test_access_sql_with_scope_narrows_by_project ... ok
test routes::scope_guard::tests::test_access_sql_without_scope_has_no_narrowing ... ok
test routes::scope_guard::tests::test_verify_org_ownership_rules ... ok
test routes::tests::test_is_allowed_origin_allows_whitelist ... ok
test routes::tests::test_is_allowed_origin_rejects_others ... ok
test routes::upload_guard::tests::test_sniff_image_ext_recognizes_bitmaps ... ok
test routes::upload_guard::tests::test_validate_image_upload_accepts_real_images ... ok
test routes::upload_guard::tests::test_validate_image_upload_rejects_scriptable_exts ... ok
test routes::upload_guard::tests::test_validate_image_upload_rejects_svg_content_with_image_ext ... ok
test routes::upload_guard::tests::test_validate_image_upload_rejects_unknown_ext ... ok
test workers::energy::tests::test_fixed_interval_is_conservative ... ok
test workers::energy::tests::test_full_hour_charge_magnitude ... ok
test workers::energy::tests::test_mwh_delta_real_world_case ... ok
test workers::energy::tests::test_mwh_delta_rejects_non_positive_inputs ... ok
test workers::energy::tests::test_mwh_delta_small_current_not_lost_by_rounding ... ok
test workers::energy::tests::test_mwh_delta_zero_current_no_accumulation ... ok
test workers::energy::tests::test_mwh_to_wh_rounding ... ok
test workers::energy::tests::test_order_energy_key_format ... ok
test workers::log_retention::tests::test_retention_constants_are_sane ... ok
test workers::report_worker::tests::test_board_channel_id_range_guards ... ok
test workers::report_worker::tests::test_build_spec_complete_topology_prefers_real_topology ... ok
test workers::report_worker::tests::test_build_spec_incomplete_topology_falls_back_to_legacy_cell ... ok
test workers::report_worker::tests::test_build_spec_legacy_cell_clamped_into_protocol_range ... ok
test workers::report_worker::tests::test_build_spec_no_cell_uses_constant_default ... ok
test workers::report_worker::tests::test_channel_battery_key_format_contract ... ok
test workers::report_worker::tests::test_build_spec_never_produces_all_zero_spec ... ok
test workers::report_worker::tests::test_channel_hex_len_constant ... ok
test workers::report_worker::tests::test_channel_status_key_format_contract ... ok
test workers::report_worker::tests::test_compartment_lock_key_is_identity ... ok
test workers::report_worker::tests::test_decide_order_action_boundary_is_strict ... ok
test workers::report_worker::tests::test_decide_order_action_replace_on_different_battery ... ok
test workers::report_worker::tests::test_decide_order_action_skip_same_battery ... ok
test workers::report_worker::tests::test_decide_order_action_create_when_no_active_order ... ok
test workers::report_worker::tests::test_decide_order_action_skip_when_either_battery_id_empty ... ok
test workers::report_worker::tests::test_compartment_lock_allows_different_compartments ... ok
test workers::report_worker::tests::test_normalize_hex_byte_rejects_invalid ... ok
test workers::report_worker::tests::test_is_unique_violation_ignores_non_database_errors ... ok
test workers::report_worker::tests::test_normalize_hex_byte_valid_and_lowercased ... ok
test workers::report_worker::tests::test_order_timeout_bound_goes_backwards ... ok
test workers::report_worker::tests::test_out_of_range_sample_contributes_zero_energy ... ok
test workers::report_worker::tests::test_parse_channel_hex_case_insensitive_and_lowercased ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_bat_in ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_bat_out ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_full ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_off ... ok
test workers::report_worker::tests::test_parse_channel_hex_empty_channel_keeps_power_status ... ok
test workers::report_worker::tests::test_parse_channel_hex_handles_multibyte_without_panic ... ok
test workers::report_worker::tests::test_parse_channel_hex_rejects_non_hex ... ok
test workers::report_worker::tests::test_parse_channel_hex_rejects_wrong_length ... ok
test workers::report_worker::tests::test_parse_compartment_id_accepts_two_segments ... ok
test workers::report_worker::tests::test_parse_compartment_id_rejects_invalid_values ... ok
test workers::report_worker::tests::test_parse_compartment_id_rejects_other_segment_counts ... ok
test workers::report_worker::tests::test_parse_compartment_id_rejects_out_of_range ... ok
test workers::report_worker::tests::test_pending_key_format_matches_device_server ... ok
test workers::report_worker::tests::test_power_fail_mask_semantics ... ok
test workers::report_worker::tests::test_sanitize_sample_value_accepts_in_range ... ok
test workers::report_worker::tests::test_sanitize_sample_value_rejects_out_of_range ... ok
test workers::report_worker::tests::test_should_finalize_bat_out_skips_late_report_of_previous_battery ... ok
test workers::report_worker::tests::test_should_finalize_bat_out_when_same_or_unknown_battery ... ok
test workers::report_worker::tests::test_compartment_lock_excludes_same_compartment ... ok
test middleware::auth::tests::test_verify_password_correct ... ok
test middleware::auth::tests::test_verify_password_wrong ... ok
test result: ok. 144 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.98s

View File

@ -0,0 +1,152 @@
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.50s
Running unittests src\main.rs (.cargo-check-target\debug\deps\pms_api-0e8b7d95b05a137e.exe)
running 145 tests
test db::migrate::tests::test_alignment_targets_cover_known_broken_columns ... ok
test db::migrate::tests::test_schema_sql_energy_columns_comment_unit_is_wh ... ok
test commands::tests::test_is_valid_command_target_rejects_imei_and_malformed ... ok
test commands::tests::test_is_valid_command_target_accepts_board_and_compartment ... ok
test db::migrate::tests::test_every_alignment_target_appears_verbatim_in_schema_sql ... ok
test db::migrate::tests::test_schema_sql_table_order_has_forward_reference_returns_none ... ok
test middleware::auth::tests::test_builtin_role_name_covers_all_valid_levels ... ok
test db::migrate::tests::test_referenced_tables_single_foreign_key_returns_table_name ... ok
test middleware::auth::tests::test_builtin_role_name_invalid_level_returns_none ... ok
test db::migrate::tests::test_legacy_column_comments_marker_matches_ddl ... ok
test middleware::auth::tests::test_builtin_role_name_operator_maps_to_normal_user ... ok
test middleware::auth::tests::test_builtin_role_name_super_admin_maps_to_super_admin ... ok
test middleware::auth::tests::test_builtin_role_name_org_admin_maps_to_org_admin ... ok
test middleware::auth::tests::test_check_permission_operator_empty_permissions_forbidden ... ok
test middleware::auth::tests::test_append_condition_handles_empty_clause ... ok
test middleware::auth::tests::test_org_condition_normal_admin ... ok
test db::migrate::tests::test_schema_sql_contains_no_timestamp_column ... ok
test middleware::auth::tests::test_org_condition_super_admin ... ok
test middleware::auth::tests::test_super_admin_role_level_constant_matches_mapping ... ok
test commands::tests::test_take_command_target_extracts_and_removes_id ... ok
test commands::tests::test_take_command_target_absent_returns_none ... ok
test commands::tests::test_take_command_target_rejects_invalid_id ... ok
test middleware::auth::tests::test_user_scope_allows_project ... ok
test db::migrate::tests::test_column_spec_from_target_parses_all_dimensions ... ok
test middleware::auth::tests::test_user_scope_default_is_unrestricted ... ok
test middleware::auth::tests::test_verify_password_invalid_hash ... ok
test middleware::auth::tests::test_org_condition_no_org ... ok
test routes::cabinet_tree::tests::test_build_filter_where_super_admin_ignores_scope ... ok
test routes::cabinet_tree::tests::test_build_filter_where_without_scope_is_unchanged ... ok
test routes::cabinet_tree::tests::test_channel_flags_has_data_and_status_code_match_frontend_mapping ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_all_idle_channels_yield_zero_full_fault ... ok
test routes::cabinet_tree::tests::test_build_filter_where_scope_combines_with_bound_filter ... ok
test middleware::auth::tests::test_user_scope_project_condition_narrows_by_project ... ok
test routes::cabinet_tree::tests::test_build_filter_where_scope_applies_to_explicit_project_and_org_params ... ok
test routes::cabinet_tree::tests::test_build_filter_where_with_scope_appends_project_narrowing ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_charging_channels_counted_as_charging ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_fault_channels_counted_as_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_fault_wins_over_full_on_conflict ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_full_channels_counted_as_full ... ok
test routes::cabinet_tree::tests::test_derive_channel_counts_offline_missing_data_yields_zero_full_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_charging_channel_sets_on_true ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_batt_fault_bits_map_to_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_empty_channel_keeps_power_status ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_full_battery_prefers_full_over_charging ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_missing_fields_yields_idle_and_empty_hex ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_normalizes_hex_case_and_padding ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_pow_fault_maps_to_fault ... ok
test routes::cabinet_tree::tests::test_derive_channel_view_single_byte_yields_empty_hex ... ok
test routes::cabinet_tree::tests::test_device_access_sql_appends_scope_only_when_configured ... ok
test routes::cabinet_tree::tests::test_device_list_fields_in_sync ... ok
test routes::cabinet_tree::tests::test_parse_channel_key_accepts_real_channel_keys ... ok
test routes::cabinet_tree::tests::test_parse_channel_key_rejects_internal_keys ... ok
test routes::cabinet_tree::tests::test_parse_channel_key_rejects_out_of_range_and_malformed ... ok
test routes::cabinet_tree::tests::test_parse_hex_byte_rejects_malformed_values ... ok
test routes::cabinet_tree::tests::test_pick_non_empty_hex_keeps_00_but_drops_missing ... ok
test routes::cabinets::tests::test_apply_realtime_keeps_null_when_missing ... ok
test routes::cabinets::tests::test_cabinet_realtime_field_indices_are_in_sync ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_cabinet_status_bits_raw_hex ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_cabinet_status_missing_is_null_not_00 ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_missing_fields_stay_none ... ok
test routes::cabinets::tests::test_parse_cabinet_realtime_reads_voltage_current_and_temp ... ok
test routes::charge_orders::tests::test_order_scope_conditions_super_admin_exempt ... ok
test routes::charge_orders::tests::test_order_scope_conditions_org_isolation ... ok
test routes::charge_records::tests::test_build_export_where_scope_appends_to_existing_conditions ... ok
test routes::charge_orders::tests::test_order_scope_conditions_applies_scope ... ok
test routes::charge_records::tests::test_build_export_where_applies_scope_project_ids ... ok
test routes::energy_stats::tests::test_build_export_clause_empty_when_no_filters ... ok
test routes::energy_stats::tests::test_build_export_clause_targets_charge_records ... ok
test routes::energy_stats::tests::test_build_export_where_keeps_org_isolation ... ok
test routes::energy_stats::tests::test_clamp_range_bounds_illegal_values ... ok
test routes::energy_stats::tests::test_estimated_cost_unit_is_wh_times_80_cent_per_kwh ... ok
test routes::energy_stats::tests::test_time_window_uses_start_time_half_open_ranges ... ok
test routes::h5::tests::test_compartment_realtime_fields_order_matches_indexes ... ok
test routes::h5::tests::test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing ... ok
test routes::h5::tests::test_parse_compartment_realtime_garbage_is_none ... ok
test routes::h5::tests::test_parse_compartment_realtime_no_data_returns_none_not_zero ... ok
test routes::h5::tests::test_parse_compartment_realtime_reads_all_fields ... ok
test routes::h5::tests::test_parse_compartment_realtime_real_zero_is_kept ... ok
test routes::h5::tests::test_visible_org_scope_operator_sees_own_org_only ... ok
test routes::h5::tests::test_visible_org_scope_org_admin_sees_own_org_only ... ok
test routes::h5::tests::test_visible_org_scope_org_user_without_org_is_empty ... ok
test routes::h5::tests::test_visible_org_scope_super_admin_sees_all ... ok
test routes::h5::tests::test_visible_orgs_own_sql_filters_by_id ... ok
test routes::scope_guard::tests::test_access_sql_with_scope_narrows_by_project ... ok
test routes::scope_guard::tests::test_access_sql_without_scope_has_no_narrowing ... ok
test routes::scope_guard::tests::test_verify_org_ownership_rules ... ok
test routes::tests::test_is_allowed_origin_allows_whitelist ... ok
test routes::tests::test_is_allowed_origin_rejects_others ... ok
test routes::upload_guard::tests::test_sniff_image_ext_recognizes_bitmaps ... ok
test routes::upload_guard::tests::test_validate_image_upload_accepts_real_images ... ok
test routes::upload_guard::tests::test_validate_image_upload_rejects_scriptable_exts ... ok
test routes::upload_guard::tests::test_validate_image_upload_rejects_svg_content_with_image_ext ... ok
test routes::upload_guard::tests::test_validate_image_upload_rejects_unknown_ext ... ok
test workers::energy::tests::test_fixed_interval_is_conservative ... ok
test workers::energy::tests::test_full_hour_charge_magnitude ... ok
test workers::energy::tests::test_mwh_delta_real_world_case ... ok
test workers::energy::tests::test_mwh_delta_rejects_non_positive_inputs ... ok
test workers::energy::tests::test_mwh_delta_small_current_not_lost_by_rounding ... ok
test workers::energy::tests::test_mwh_delta_zero_current_no_accumulation ... ok
test workers::energy::tests::test_mwh_to_wh_rounding ... ok
test workers::energy::tests::test_order_energy_key_format ... ok
test workers::log_retention::tests::test_retention_constants_are_sane ... ok
test workers::report_worker::tests::test_board_channel_id_range_guards ... ok
test workers::report_worker::tests::test_build_spec_complete_topology_prefers_real_topology ... ok
test workers::report_worker::tests::test_build_spec_incomplete_topology_falls_back_to_legacy_cell ... ok
test workers::report_worker::tests::test_build_spec_legacy_cell_clamped_into_protocol_range ... ok
test workers::report_worker::tests::test_build_spec_never_produces_all_zero_spec ... ok
test workers::report_worker::tests::test_build_spec_no_cell_uses_constant_default ... ok
test workers::report_worker::tests::test_channel_battery_key_format_contract ... ok
test workers::report_worker::tests::test_channel_hex_len_constant ... ok
test workers::report_worker::tests::test_channel_status_key_format_contract ... ok
test workers::report_worker::tests::test_compartment_lock_key_is_identity ... ok
test workers::report_worker::tests::test_decide_order_action_boundary_is_strict ... ok
test workers::report_worker::tests::test_decide_order_action_create_when_no_active_order ... ok
test workers::report_worker::tests::test_decide_order_action_replace_on_different_battery ... ok
test workers::report_worker::tests::test_decide_order_action_skip_same_battery ... ok
test workers::report_worker::tests::test_decide_order_action_skip_when_either_battery_id_empty ... ok
test workers::report_worker::tests::test_is_unique_violation_ignores_non_database_errors ... ok
test workers::report_worker::tests::test_normalize_hex_byte_rejects_invalid ... ok
test workers::report_worker::tests::test_normalize_hex_byte_valid_and_lowercased ... ok
test workers::report_worker::tests::test_order_timeout_bound_goes_backwards ... ok
test workers::report_worker::tests::test_out_of_range_sample_contributes_zero_energy ... ok
test workers::report_worker::tests::test_parse_channel_hex_case_insensitive_and_lowercased ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_bat_in ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_bat_out ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_full ... ok
test workers::report_worker::tests::test_parse_channel_hex_doc_example_off ... ok
test workers::report_worker::tests::test_parse_channel_hex_empty_channel_keeps_power_status ... ok
test workers::report_worker::tests::test_parse_channel_hex_handles_multibyte_without_panic ... ok
test workers::report_worker::tests::test_parse_channel_hex_rejects_non_hex ... ok
test workers::report_worker::tests::test_parse_channel_hex_rejects_wrong_length ... ok
test workers::report_worker::tests::test_parse_compartment_id_accepts_two_segments ... ok
test workers::report_worker::tests::test_parse_compartment_id_rejects_invalid_values ... ok
test workers::report_worker::tests::test_parse_compartment_id_rejects_other_segment_counts ... ok
test workers::report_worker::tests::test_parse_compartment_id_rejects_out_of_range ... ok
test workers::report_worker::tests::test_pending_key_format_matches_device_server ... ok
test workers::report_worker::tests::test_power_fail_mask_semantics ... ok
test workers::report_worker::tests::test_sanitize_sample_value_accepts_in_range ... ok
test workers::report_worker::tests::test_sanitize_sample_value_rejects_out_of_range ... ok
test workers::report_worker::tests::test_should_finalize_bat_out_skips_late_report_of_previous_battery ... ok
test workers::report_worker::tests::test_should_finalize_bat_out_when_same_or_unknown_battery ... ok
test workers::report_worker::tests::test_compartment_lock_allows_different_compartments ... ok
test workers::report_worker::tests::test_compartment_lock_excludes_same_compartment ... ok
test middleware::auth::tests::test_verify_password_correct ... ok
test middleware::auth::tests::test_verify_password_wrong ... ok
test result: ok. 145 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.05s

View File

@ -0,0 +1,76 @@
"""只读比对:pms_dev 与 pms 的目标列定义 + organizations.parent_id 索引/外键
用法: python .tmp-schema-align/compare.py
只发 SELECT,不做任何写入。
"""
import pymysql
COLS = [
("charge_records", "energy"),
("charge_records", "start_time"),
("charge_records", "end_time"),
("energy_stats", "total_energy"),
("download_tasks", "completed_at"),
("organizations", "parent_id"),
]
COL_SQL = """
SELECT COLUMN_TYPE, IS_NULLABLE, IFNULL(CAST(COLUMN_DEFAULT AS CHAR),'<NULL>'),
IFNULL(CAST(COLUMN_COMMENT AS CHAR),'')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s AND COLUMN_NAME=%s
"""
IDX_SQL = """
SELECT INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=%s AND TABLE_NAME='organizations'
ORDER BY INDEX_NAME, SEQ_IN_INDEX
"""
FK_SQL = """
SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA=%s AND TABLE_NAME='organizations' AND REFERENCED_TABLE_NAME IS NOT NULL
"""
def snapshot(db):
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db)
cur = c.cursor()
cols = {}
for t, col in COLS:
cur.execute(COL_SQL, (db, t, col))
cols[f"{t}.{col}"] = cur.fetchone() or ("<MISSING>",) * 4
cur.execute(IDX_SQL, (db,))
idx = cur.fetchall()
cur.execute(FK_SQL, (db,))
fk = cur.fetchall()
c.close()
return cols, idx, fk
dev_cols, dev_idx, dev_fk = snapshot("pms_dev")
prd_cols, prd_idx, prd_fk = snapshot("pms")
print("=" * 100)
print("%-32s | %-44s | %-44s | %s" % ("列", "pms_dev(测试)", "pms(生产)", "一致"))
print("=" * 100)
all_same = True
for key in [f"{t}.{c}" for t, c in COLS]:
d, p = dev_cols[key], prd_cols[key]
same = d == p
all_same &= same
fmt = lambda r: "%s null=%s def=%s cmt=%s" % r
print("%-32s | %-44s | %-44s | %s" % (key, fmt(d), fmt(p), "OK" if same else "DIFF"))
print("-" * 100)
print("列定义完全一致:", all_same)
print()
print("organizations 索引 pms_dev :", dev_idx)
print("organizations 索引 pms :", prd_idx)
print("parent_id 处于索引最左列 :", any(r[1] == 1 and r[2] == "parent_id" for r in dev_idx),
"/", any(r[1] == 1 and r[2] == "parent_id" for r in prd_idx))
print()
print("organizations 自引用外键 pms_dev :", dev_fk)
print("organizations 自引用外键 pms :", prd_fk)
print("外键均存在:", bool(dev_fk) and bool(prd_fk))

View File

@ -0,0 +1,58 @@
"""只读比对脚本:逐列比对 pms_dev(测试) 与 pms(生产) 的**全部**列定义
比对维度:COLUMN_TYPE / IS_NULLABLE / COLUMN_DEFAULT / EXTRA / COLUMN_COMMENT
另外单独统计两库是否还残留 `TIMESTAMP` 列(本项目禁止:sqlx 只能把它解码成 DateTime<Utc>,
而 Rust 侧时间列全是 NaiveDateTime/Option<NaiveDateTime> ⇒ 接口 500)。
用法: python .tmp-schema-align/compare_full.py
只发 SELECT,不做任何写入。
"""
import pymysql
COLSQL = """
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
IFNULL(CAST(COLUMN_DEFAULT AS CHAR),'<NULL>'),
IFNULL(CAST(EXTRA AS CHAR),''),
IFNULL(CAST(COLUMN_COMMENT AS CHAR),'')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN (
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA=%s AND TABLE_TYPE='BASE TABLE')
ORDER BY TABLE_NAME, ORDINAL_POSITION
"""
def snap(db):
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db)
cur = c.cursor()
cur.execute(COLSQL, (db, db))
rows = {(r[0], r[1]): r[2:] for r in cur.fetchall()}
c.close()
return rows
dev, prd = snap("pms_dev"), snap("pms")
keys = sorted(set(dev) | set(prd))
print("=" * 118)
print("两库列定义比对:pms_dev(测试) vs pms(生产) 共 %d 列" % len(keys))
print("=" * 118)
diffs = []
for k in keys:
d, p = dev.get(k), prd.get(k)
if d != p:
diffs.append((k, d, p))
if not diffs:
print("✅ 全部 %d 列的 类型/可空性/默认值/EXTRA/注释 完全一致" % len(keys))
else:
print("❌ 仍有 %d 列不一致:" % len(diffs))
for k, d, p in diffs:
print(" %-32s dev=%s" % (f"{k[0]}.{k[1]}", d))
print(" %-32s prd=%s" % ("", p))
print()
ts_dev = sorted(k for k, v in dev.items() if v[0].startswith("timestamp"))
ts_prd = sorted(k for k, v in prd.items() if v[0].startswith("timestamp"))
print("pms_dev 残留 TIMESTAMP 列: %d %s" % (len(ts_dev), [f"{t}.{c}" for t, c in ts_dev]))
print("pms 残留 TIMESTAMP 列: %d %s" % (len(ts_prd), [f"{t}.{c}" for t, c in ts_prd]))

View File

@ -0,0 +1,42 @@
"""列出两库全部时间列的类型,并做差异比对(只读)"""
import pymysql
SQL = """
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE,
IFNULL(CAST(COLUMN_DEFAULT AS CHAR),'<NULL>')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s AND DATA_TYPE IN ('timestamp','datetime')
ORDER BY TABLE_NAME, ORDINAL_POSITION
"""
def snap(db):
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db)
cur = c.cursor()
cur.execute(SQL, (db,))
rows = {(r[0], r[1]): (r[2], r[3], r[4]) for r in cur.fetchall()}
c.close()
return rows
dev, prd = snap("pms_dev"), snap("pms")
keys = sorted(set(dev) | set(prd))
print("%-30s %-20s %-34s %-34s %s" % ("table.column", "", "pms_dev", "pms", "一致"))
diff = 0
for k in keys:
d, p = dev.get(k, ("<missing>",) * 3), prd.get(k, ("<missing>",) * 3)
same = d == p
if not same:
diff += 1
print("%-30s %-20s %-34s %-34s %s" % (f"{k[0]}.{k[1]}", "", str(d), str(p), "OK" if same else "*** DIFF ***"))
print()
print("时间列总数:", len(keys), " 不一致:", diff)
print()
print("pms_dev 中仍是 TIMESTAMP 的列:")
for k in keys:
if dev.get(k, ("",))[0] == "timestamp":
print(" ", f"{k[0]}.{k[1]}", dev[k])
print("pms(生产) 中仍是 TIMESTAMP 的列:")
for k in keys:
if prd.get(k, ("",))[0] == "timestamp":
print(" ", f"{k[0]}.{k[1]}", prd[k])

View File

@ -0,0 +1,12 @@
import pymysql, sys
SQL = sys.argv[1]
c = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms_dev', autocommit=True)
cur = c.cursor()
cur.execute(SQL)
try:
for r in cur.fetchall():
print(r)
except Exception:
pass
print("lastrowid", cur.lastrowid, "rowcount", cur.rowcount)

View File

@ -0,0 +1,41 @@
"""列出两库时间列的完整定义(含 EXTRA),用于生成对齐清单(只读)"""
import pymysql
SQL = """
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE,
IFNULL(CAST(COLUMN_DEFAULT AS CHAR),'<NULL>'),
EXTRA, IFNULL(CAST(COLUMN_COMMENT AS CHAR),'')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s AND DATA_TYPE IN ('timestamp','datetime')
ORDER BY TABLE_NAME, ORDINAL_POSITION
"""
def snap(db):
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db)
cur = c.cursor()
cur.execute(SQL, (db,))
rows = {(r[0], r[1]): r[2:] for r in cur.fetchall()}
c.close()
return rows
dev, prd = snap("pms_dev"), snap("pms")
print("== 生产(pms)时间列定义(作为目标)==")
for k in sorted(prd):
print(" %-28s %-34s %s" % (f"{k[0]}.{k[1]}", str(prd[k][:3]), prd[k][3]))
print()
print("== 仅 dev != prod 的列(扩展对齐候选)==")
n = 0
for k in sorted(set(dev) | set(prd)):
d, p = dev.get(k), prd.get(k)
if d != p:
n += 1
print(" %-28s dev=%s" % (f"{k[0]}.{k[1]}", d))
print(" %-28s prd=%s" % ("", p))
print("共", n, "列")
print()
print("== 两库相同的 TIMESTAMP 列(不在漂移范围,需单独决策)==")
for k in sorted(set(dev) & set(prd)):
if dev[k][0] == "timestamp":
print(" %-28s %s" % (f"{k[0]}.{k[1]}", dev[k]))

View File

@ -0,0 +1,106 @@
=== 2/8 校验产物与静态检查 ===
✅ pms-api (12.1 MB)
✅ pms-device-server (2.8 MB)
✅ web/dist (6 个文件)
=== 3/8 服务器备份(保留旧二进制/.env/nginx/单元/前端) ===
[DryRun] ssh: set -e
TS=$(date +%Y%m%d-%H%M%S)
mkdir -p /data/pms/backup-$TS
cp -a /data/pms/api-server/pms-api /data/pms/backup-$TS/ 2>/dev/null || true
cp -a /data/pms/device-server/pms-device-server /data/pms/backup-$TS/ 2>/dev/null || true
cp -a /data/pms/api-server/.env /data/pms/device-server/.env /data/pms/backup-$TS/ 2>/dev/null || true
mkdir -p /data/pms/backup-$TS/web && cp -a /data/pms/web/. /data/pms/backup-$TS/web/ 2>/dev/null || true
echo $TS > /tmp/pms_last_backup
echo " 备份目录: /data/pms/backup-$TS"
=== 4/8 上传(二进制传 .new,运行中的二进制不可直接覆盖) ===
[DryRun] scp: D:\charging-cabinet\software\api-server\target\x86_64-unknown-linux-gnu\release\pms-api -> /data/pms/api-server/pms-api.new
[DryRun] scp: D:\charging-cabinet\software\device-server\target\x86_64-unknown-linux-gnu\release\pms-device-server -> /data/pms/device-server/pms-device-server.new
[DryRun] scp: C:\Users\12451\AppData\Local\Temp\pms-dist.tgz -> /tmp/pms-dist.tgz
=== 5/8 原子替换并重启服务 ===
[DryRun] ssh: set -e
chmod +x /data/pms/api-server/pms-api.new /data/pms/device-server/pms-device-server.new
systemctl stop pms-api pms-device
mv -f /data/pms/api-server/pms-api.new /data/pms/api-server/pms-api
mv -f /data/pms/device-server/pms-device-server.new /data/pms/device-server/pms-device-server
rm -rf /data/pms/web/* && tar -xzf /tmp/pms-dist.tgz -C /data/pms/web/ && rm -f /tmp/pms-dist.tgz
systemctl daemon-reload
systemctl start pms-device pms-api
sleep 8
nginx -t && systemctl reload nginx
=== 6/8 验收(服务/端口/域名/日志) ===
[DryRun] ssh: echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
echo '--- 端口(应为 9000 / 9001)---'; ss -lntp | grep -E ':9000|:9001' | awk '{print " " $4}'
echo '--- 域名 ---'
curl -sk -m 8 -o /dev/null -w ' /api/pms/cabinets -> %{http_code}\n' https://app.anzhizhichong.com/api/pms/cabinets
curl -sk -m 8 -o /dev/null -w ' /pms/ -> %{http_code}\n' https://app.anzhizhichong.com/pms/
echo '--- 启动日志尾部 ---'; journalctl -u pms-api --no-pager -n 4 | tail -4
echo '--- 新进程 ERROR 数(应为 0)---'
MAIN=$(systemctl show -p MainPID --value pms-api)
journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^/ ERROR 行数: /'
=== 7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据) ===
=== 8/8 回滚点保留策略(只保留最近 5 次 backup-YYYYMMDD-HHMMSS) ===
[DryRun] ssh: set -e
shopt -s nullglob
KEEP=5
# glob 字符类只匹配严格的 backup-YYYYMMDD-HHMMSS;nullglob 让无匹配时展开为空数组。
# glob 结果按字典序排列,对该命名格式而言等价于时间戳升序(最早的在前)。
rollbacks=()
for p in /data/pms/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "$p" ]; then
rollbacks+=( "$p" )
fi
done
total=${#rollbacks[@]}
echo " 当前回滚点 $total 个(deploy 会为每次部署新建一份):"
for p in "${rollbacks[@]}"; do
echo " $p"
done
if [ "$total" -gt "$KEEP" ]; then
drop=$((total - KEEP))
echo " 将删除最早的 $drop 个(保留最近 $KEEP 个):"
i=0
for p in "${rollbacks[@]}"; do
if [ "$i" -ge "$drop" ]; then
break
fi
i=$((i + 1))
# 复核:名字必须严格符合 backup-YYYYMMDD-HHMMSS,否则拒绝删除(绝不碰其它目录)
case "$(basename "$p")" in
backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
echo " 删除 $p"
rm -rf -- "$p"
;;
*)
echo " [skip] 名字不符合回滚点模式,拒绝删除: $p"
;;
esac
done
else
echo " 回滚点数量未超过 $KEEP,无需清理"
fi
kept=()
for p in /data/pms/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "$p" ]; then
kept+=( "$p" )
fi
done
echo " 清理后保留的回滚点 ${#kept[@]} 个:"
for p in "${kept[@]}"; do
echo " $p"
done
echo " /data/pms 占用: $(du -sh /data/pms | cut -f1)"
=== 部署完成 ===
正式环境:https://app.anzhizhichong.com/pms/
API :9000 TCP :9001
回滚方法(如需):
plink -batch -pw <pwd> root@118.31.119.206 "systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device"
备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 5 次)

View File

@ -0,0 +1,152 @@
"""端点层验收:造临时数据 → 调接口 → 断言 200 且能返回行 → 清理临时数据
用法:
python .tmp-schema-align/endpoint_verify.py dev # 测试环境(pms_dev + test.anzhizhichong.com)
python .tmp-schema-align/endpoint_verify.py prod # 生产环境(pms + app.anzhizhichong.com)
python .tmp-schema-align/endpoint_verify.py dev clean # 只清理
"""
import base64, hashlib, hmac, json, ssl, sys, time, urllib.request, urllib.error, pymysql
ENVS = {
"dev": dict(
db="pms_dev",
base="https://test.anzhizhichong.com/api/pms",
secret="32e8708743c42fe764aff1d40737a63803a46815a9501f4bcb50c41fe3d27108",
admin_phone="19900000001",
admin_role=2,
),
"prod": dict(
db="pms",
base="https://app.anzhizhichong.com/api/pms",
secret="787789e8eb1f3a10e81596f8219a20aa3c34102acacba2a5ed14bbb1d16ec833",
admin_phone="13912391234",
admin_role=2,
# 生产库**不新建账号**:直接复用既有的总管理员账号(id=2)签 token,
# 只造 charge_orders / device_logs 两条临时业务行,验完立刻删除。
existing_admin_id=2,
),
}
TAG = "VERIFY-SCHEMA-TMP"
def b64(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def sign(secret, sub, phone):
now = int(time.time())
seg = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + "." + \
b64(json.dumps({"sub": sub, "phone": phone, "exp": now + 1800, "iat": now},
separators=(",", ":")).encode())
return seg + "." + b64(hmac.new(secret.encode(), seg.encode(), hashlib.sha256).digest())
def call(base, path, token):
req = urllib.request.Request(base + path, headers={"Authorization": "Bearer " + token})
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=25, context=ctx) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def main():
env = ENVS[sys.argv[1]]
only_clean = len(sys.argv) > 2 and sys.argv[2] == "clean"
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@",
database=env["db"], autocommit=True)
cur = c.cursor()
# ---- 清理上一轮遗留 ----
creates_admin = "existing_admin_id" not in env
cur.execute("DELETE FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
cur.execute("DELETE FROM charge_orders WHERE battery_id = %s", (TAG,))
if creates_admin:
# 只删自己造的临时账号;复用既有账号时**绝不能**碰 users 表
cur.execute("DELETE FROM users WHERE phone = %s", (env["admin_phone"],))
if only_clean:
print("已清理临时数据(device_logs/charge_orders/users)")
return
# ---- 取一个真实拓扑:柜子 + 仓体(满足外键)----
cur.execute(
"SELECT c.id, cb.cabinet_id FROM compartments c "
"JOIN cabin_boards cb ON c.cabin_board_id = cb.id ORDER BY c.id LIMIT 1"
)
row = cur.fetchone()
if not row:
sys.exit("库里没有 compartments,无法造订单(外键需要)")
compartment_id, cabinet_id = row
# ---- 造临时超管(JWT 只带 sub,角色与权限从库里读)----
if creates_admin:
cur.execute(
"INSERT INTO users (phone, name, password, role, organization_id, status) VALUES (%s, %s, 'x', %s, NULL, 1)",
(env["admin_phone"], TAG, env["admin_role"]),
)
admin_id = cur.lastrowid
else:
admin_id = env["existing_admin_id"]
# ---- 造 2 条 charge_orders(status<>0 ⇒ active_flag=NULL,不撞唯一约束)----
cur.execute(
"INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_time, end_time, "
"start_soc, end_soc, energy, reason, status) "
"VALUES (%s, %s, %s, NOW(), NOW(), 850, 1000, 22, 'normal', 1)",
(compartment_id, cabinet_id, TAG),
)
order_id = cur.lastrowid
# ---- 造 2 条 device_logs ----
cur.execute(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (%s, 1, %s)",
(cabinet_id, json.dumps({"tag": TAG, "n": 1})),
)
log_id = cur.lastrowid
print(f"环境={sys.argv[1]} 库={env['db']} 临时数据: users.id={admin_id} "
f"charge_orders.id={order_id} device_logs.id={log_id} (cabinet_id={cabinet_id})")
# ---- 自签 token 调接口 ----
token = sign(env["secret"], admin_id, env["admin_phone"])
results = {}
for path in ("/charge-orders?page=1&page_size=20", "/device-logs?page=1&page_size=20",
"/charge-records?page=1&page_size=5"):
status, body = call(env["base"], path, token)
results[path] = (status, body)
has_tmp = TAG in body
print(f"\n--- GET {path} -> HTTP {status} (临时行出现={has_tmp}) ---")
print(body[:600])
# ---- 断言 ----
ok_orders = results["/charge-orders?page=1&page_size=20"][0] == 200 and TAG in results["/charge-orders?page=1&page_size=20"][1]
ok_logs = results["/device-logs?page=1&page_size=20"][0] == 200 and TAG in results["/device-logs?page=1&page_size=20"][1]
ok_records = results["/charge-records?page=1&page_size=5"][0] == 200
print("\n==== 断言 ====")
print(" /api/charge-orders 200 且返回临时行:", "PASS" if ok_orders else "FAIL")
print(" /api/device-logs 200 且返回临时行:", "PASS" if ok_logs else "FAIL")
print(" /api/charge-records 200 :", "PASS" if ok_records else "FAIL")
print("\n==== 清理临时数据 ====")
cur.execute("DELETE FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
print(" device_logs 删除行数:", cur.rowcount)
cur.execute("DELETE FROM charge_orders WHERE battery_id = %s", (TAG,))
print(" charge_orders 删除行数:", cur.rowcount)
if creates_admin:
cur.execute("DELETE FROM users WHERE phone = %s", (env["admin_phone"],))
print(" users 删除行数:", cur.rowcount)
else:
print(" users: 未新建账号(复用既有超管 id=%s),无需删除" % admin_id)
cur.execute("SELECT COUNT(*) FROM charge_orders WHERE battery_id=%s", (TAG,))
print(" 残留 charge_orders:", cur.fetchone()[0])
cur.execute("SELECT COUNT(*) FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
print(" 残留 device_logs:", cur.fetchone()[0])
c.close()
sys.exit(0 if (ok_orders and ok_logs and ok_records) else 1)
main()

View File

@ -0,0 +1,51 @@
"""自签 HS256 JWT 并调用测试环境导出接口(只读探测,不写业务数据)"""
import base64, hashlib, hmac, json, ssl, sys, time, urllib.request, pymysql
JWT_SECRET = "32e8708743c42fe764aff1d40737a63803a46815a9501f4bcb50c41fe3d27108"
BASE = "https://test.anzhizhichong.com/api/pms"
def b64(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def sign(sub: int, phone: str) -> str:
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": sub, "phone": phone, "exp": now + 3600, "iat": now}
seg = b64(json.dumps(header, separators=(",", ":")).encode()) + "." + \
b64(json.dumps(payload, separators=(",", ":")).encode())
sig = hmac.new(JWT_SECRET.encode(), seg.encode(), hashlib.sha256).digest()
return seg + "." + b64(sig)
def call(path: str, token: str):
req = urllib.request.Request(BASE + path, headers={"Authorization": "Bearer " + token})
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=20, context=ctx) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
conn = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev")
cur = conn.cursor()
cur.execute("SELECT id, phone, role FROM users WHERE role >= 2 ORDER BY id LIMIT 5")
admins = cur.fetchall()
print("super admins:", admins)
conn.close()
if not admins:
sys.exit("no super admin")
uid, phone, _ = admins[0]
tok = sign(uid, phone or "13800000000")
print("token(sub=%s) len=%d" % (uid, len(tok)))
if len(sys.argv) > 1 and sys.argv[1] == "orders":
print("charge-orders:", call("/charge-orders?page=1&page_size=5", tok))
else:
print("charge-records list:", call("/charge-records?page=1&page_size=5", tok))
print("charge-records export:", call("/charge-records/export", tok))

View File

@ -0,0 +1,67 @@
"""最终验收:测试环境导出功能(GET /api/charge-records/export)→ 检查 xlsx → 清理
用法: python .tmp-schema-align\export_verify.py
"""
import base64, hashlib, hmac, json, ssl, subprocess, sys, time, urllib.request, urllib.error, pymysql
SECRET = "32e8708743c42fe764aff1d40737a63803a46815a9501f4bcb50c41fe3d27108"
BASE = "https://test.anzhizhichong.com/api/pms"
PHONE = "19900000003"
def b64(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def sign(sub):
now = int(time.time())
seg = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + "." + \
b64(json.dumps({"sub": sub, "phone": PHONE, "exp": now + 1800, "iat": now},
separators=(",", ":")).encode())
return seg + "." + b64(hmac.new(SECRET.encode(), seg.encode(), hashlib.sha256).digest())
def call(path, token):
req = urllib.request.Request(BASE + path, headers={"Authorization": "Bearer " + token})
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=30, context=ctx) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@",
database="pms_dev", autocommit=True)
cur = c.cursor()
cur.execute("DELETE FROM download_tasks WHERE user_id IN (SELECT id FROM users WHERE phone=%s)", (PHONE,))
cur.execute("DELETE FROM users WHERE phone=%s", (PHONE,))
cur.execute("INSERT INTO users (phone,name,password,role,organization_id,status) VALUES (%s,'VERIFY-EXPORT-TMP','x',2,NULL,1)", (PHONE,))
admin_id = cur.lastrowid
tok = sign(admin_id)
print("临时超管 id=", admin_id)
status, body = call("/charge-records/export", tok)
print("GET /api/charge-records/export ->", status, body)
task_id = json.loads(body)["data"]["task_id"]
time.sleep(4)
cur.execute("SELECT id,status,progress,file_path,completed_at,IFNULL(error_message,'') FROM download_tasks WHERE id=%s", (task_id,))
row = cur.fetchone()
print("download_tasks 行:", row)
ok = row and row[1] == 2 and row[2] == 100 and row[3]
print("断言:任务 status=2(完成) 且 progress=100 且 file_path 非空 ->", "PASS" if ok else "FAIL")
print("\n--- 清理 ---")
cur.execute("DELETE FROM download_tasks WHERE user_id=%s", (admin_id,))
print(" download_tasks 删除:", cur.rowcount)
cur.execute("DELETE FROM users WHERE id=%s", (admin_id,))
print(" users 删除:", cur.rowcount)
cur.execute("SELECT COUNT(*) FROM download_tasks")
print(" download_tasks 剩余行:", cur.fetchone()[0])
c.close()
print(" 导出的 xlsx 由服务器侧脚本删除(见 remote 输出)")
sys.exit(0 if ok else 1)

View File

@ -0,0 +1,8 @@
# 从 -DryRun 输出中抽取第 8 步渲染出来的 bash 原文,落到本地 .sh(验证转义/语法/逻辑)
$text = Get-Content 'D:\charging-cabinet\.tmp-schema-align\dryrun.out.txt' -Raw
$m = [regex]::Match($text, '(?ms)^\s*\[DryRun\] ssh: (set -e\r?\nshopt -s nullglob\r?\n.*?)(?=\r?\n\r?\n)')
if (-not $m.Success) { throw '未找到第 8 步 DryRun 输出' }
$bash = $m.Groups[1].Value -replace "`r`n", "`n"
Set-Content -NoNewline -Encoding utf8 'D:\charging-cabinet\.tmp-schema-align\retention.sh' $bash
Write-Host "抽取字符数: $($bash.Length)"
Write-Host "含 /data/pms 次数: $(([regex]::Matches($bash,'/data/pms')).Count)"

View File

@ -0,0 +1,17 @@
import pymysql
for db in ("pms_dev", "pms"):
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db)
cur = c.cursor()
out = []
checks = [
("charge_orders WHERE battery_id LIKE 'VERIFY-%'", "charge_orders(tmp)"),
("device_logs WHERE content LIKE '%VERIFY-%'", "device_logs(tmp)"),
("download_tasks", "download_tasks(全部)"),
("users WHERE name LIKE 'VERIFY-%' OR phone LIKE '1990000%'", "users(tmp)"),
]
for where, label in checks:
cur.execute("SELECT COUNT(*) FROM %s" % where)
out.append("%s=%d" % (label, cur.fetchone()[0]))
print(" ", db, ", ".join(out))
c.close()

View File

@ -0,0 +1,61 @@
import pymysql, sys
TARGETS = [
("charge_records", "energy"),
("charge_records", "start_time"),
("charge_records", "end_time"),
("energy_stats", "total_energy"),
("download_tasks", "completed_at"),
("organizations", "parent_id"),
]
SQL = """
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, DATA_TYPE, IS_NULLABLE,
COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s AND ((TABLE_NAME, COLUMN_NAME) IN (%s))
ORDER BY TABLE_NAME, COLUMN_NAME
"""
IDX = """
SELECT TABLE_NAME, INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) cols, NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN ('organizations','download_tasks','energy_stats','charge_records','charge_orders')
GROUP BY TABLE_NAME, INDEX_NAME, NON_UNIQUE ORDER BY TABLE_NAME, INDEX_NAME
"""
FK = """
SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA=%s AND REFERENCED_TABLE_NAME IS NOT NULL
ORDER BY TABLE_NAME, CONSTRAINT_NAME
"""
for db in ("pms_dev", "pms"):
conn = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db, charset="utf8mb4")
cur = conn.cursor()
print("=" * 78)
print("DB:", db)
print("=" * 78)
placeholders = ",".join(["(%s,%s)"] * len(TARGETS))
flat = [db] + [x for t in TARGETS for x in t]
cur.execute(SQL % ("%s", placeholders), flat)
for r in cur.fetchall():
print(f" {r[0]}.{r[1]:<14} type={r[2]:<18} data={r[3]:<10} null={r[4]:<4} default={str(r[5]):<20} extra={r[6]:<20} comment={r[7]}")
print(" -- 索引 --")
cur.execute(IDX, (db,))
for r in cur.fetchall():
print(f" {r[0]:<18} {r[1]:<38} ({r[2]}) uniq={0 if r[3]==0 else 1}")
print(" -- 外键 --")
cur.execute(FK, (db,))
for r in cur.fetchall():
print(f" {r[0]:<18} {r[1]:<40} {r[2]} -> {r[3]}.{r[4]}")
# row counts
print(" -- 行数 --")
for t in ("charge_records", "energy_stats", "download_tasks", "organizations", "charge_orders"):
try:
cur.execute(f"SELECT COUNT(*) FROM {t}")
print(f" {t:<18} {cur.fetchone()[0]}")
except Exception as e:
print(f" {t:<18} ERR {e}")
conn.close()

View File

@ -0,0 +1,37 @@
import pymysql
def q(db, sql):
conn = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db, charset="utf8mb4")
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
conn.close()
return rows
for db in ("pms_dev", "pms"):
print("==== ", db)
for r in q(db, """
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE,
IFNULL(CAST(COLUMN_DEFAULT AS CHAR),'<NULL>') AS dflt,
COLUMN_TYPE, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '%s'
AND ((TABLE_NAME='charge_records' AND COLUMN_NAME IN ('energy','start_time','end_time'))
OR (TABLE_NAME='energy_stats' AND COLUMN_NAME='total_energy')
OR (TABLE_NAME='download_tasks' AND COLUMN_NAME='completed_at')
OR (TABLE_NAME='organizations' AND COLUMN_NAME='parent_id'))
ORDER BY TABLE_NAME, COLUMN_NAME""" % db):
print(" ", r)
print("==== pms_dev data")
print("charge_records:", q("pms_dev", "SELECT id, compartment_id, cabinet_id, start_time, end_time, energy FROM charge_records"))
print("download_tasks:", q("pms_dev", "SELECT id, task_type, status, completed_at FROM download_tasks"))
print("organizations(parent_id not null):", q("pms_dev", "SELECT COUNT(*) FROM organizations WHERE parent_id IS NOT NULL"))
print("pms organizations count:", q("pms", "SELECT COUNT(*) FROM organizations"))
print("pms fk/index on organizations.parent_id:", q("pms", "SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='pms' AND TABLE_NAME='organizations'"))
print("pms create table organizations:\n", q("pms", "SHOW CREATE TABLE organizations")[0][1])
print("pms_dev create table organizations:\n", q("pms_dev", "SHOW CREATE TABLE organizations")[0][1])
print("pms create table charge_records:\n", q("pms", "SHOW CREATE TABLE charge_records")[0][1])
print("pms_dev create table charge_records:\n", q("pms_dev", "SHOW CREATE TABLE charge_records")[0][1])
print("pms_dev create table download_tasks:\n", q("pms_dev", "SHOW CREATE TABLE download_tasks")[0][1])
print("pms create table download_tasks:\n", q("pms", "SHOW CREATE TABLE download_tasks")[0][1])

View File

@ -0,0 +1,18 @@
import pymysql
SQL = """
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
IFNULL(CAST(COLUMN_DEFAULT AS CHAR),'<NULL>') AS dflt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ('charge_orders','charge_records','download_tasks','energy_stats')
ORDER BY TABLE_NAME, ORDINAL_POSITION
"""
for db in ("pms_dev", "pms"):
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database=db)
cur = c.cursor()
cur.execute(SQL, (db,))
print("==", db)
for r in cur.fetchall():
print(" %-16s %-18s %-14s null=%-4s default=%s" % r)
c.close()

View File

@ -0,0 +1,42 @@
"""只读探测生产环境 /api/charge-orders(验证同一解码缺陷是否在生产同样存在)"""
import base64, hashlib, hmac, json, ssl, time, urllib.request, urllib.error, pymysql
JWT_SECRET = "787789e8eb1f3a10e81596f8219a20aa3c34102acacba2a5ed14bbb1d16ec833"
BASE = "https://app.anzhizhichong.com/api/pms"
def b64(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def sign(sub, phone):
now = int(time.time())
seg = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + "." + \
b64(json.dumps({"sub": sub, "phone": phone, "exp": now + 600, "iat": now},
separators=(",", ":")).encode())
return seg + "." + b64(hmac.new(JWT_SECRET.encode(), seg.encode(), hashlib.sha256).digest())
def call(path, token):
req = urllib.request.Request(BASE + path, headers={"Authorization": "Bearer " + token})
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=20, context=ctx) as r:
return r.status, r.read().decode("utf-8", "replace")[:300]
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")[:300]
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms")
cur = c.cursor()
cur.execute("SELECT id, phone, role FROM users WHERE role >= 2 ORDER BY id LIMIT 5")
admins = cur.fetchall()
c.close()
print("prod super admins:", admins)
uid, phone, _ = admins[0]
tok = sign(uid, phone)
for p in ("/charge-orders?page=1&page_size=2", "/users?page=1&page_size=2",
"/charge-records?page=1&page_size=2", "/batteries?page=1&page_size=2"):
print(p, "->", call(p, tok))

View File

@ -0,0 +1,6 @@
echo "=== ???? ==="
df -h /data | tail -1
echo "=== ?? /usr/local/bin/pms-backup-db.sh??? pms + pms_dev?==="
/usr/local/bin/pms-backup-db.sh
echo "=== ???? ==="
ls -la /data/backup/mysql/*/ 2>/dev/null

View File

@ -0,0 +1,5 @@
echo "=== ??????????? ==="
/usr/local/bin/pms-backup-db.sh
ls -la /data/backup/mysql/2026-09-20/
echo "=== ??????? DDL ???==="
mysql --defaults-extra-file=/root/.my.cnf -N -B -e "SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema='pms' ORDER BY table_rows DESC LIMIT 8"

View File

@ -0,0 +1 @@
/usr/local/bin/pms-backup-db.sh

View File

@ -0,0 +1,20 @@
set -e
bash --version | head -1
echo "=== ?????7 ???? + ????.env ??????????????????==="
rm -rf /tmp/retention-test
mkdir -p /tmp/retention-test
for ts in 20260918-010101 20260919-020202 20260919-065901 20260920-000048 20260920-003606 20260920-012048 20260920-023000; do
mkdir -p /tmp/retention-test/backup-$ts
head -c 1024 /dev/zero > /tmp/retention-test/backup-$ts/pms-api
done
echo "??????" > /tmp/retention-test/backup-20260920-004848-api.env
echo "??????????" > /tmp/retention-test/backup-2026092-010101
mkdir -p /tmp/retention-test/web /tmp/retention-test/api-server
echo "=== ????? ==="
ls -la /tmp/retention-test | grep backup
echo "=== ???8? bash??? /data/pms ?? /tmp/retention-test?==="
sed 's#/data/pms#/tmp/retention-test#g' /tmp/retention.sh > /tmp/retention-test.sh
bash -n /tmp/retention-test.sh && echo "bash -n OK"
bash /tmp/retention-test.sh
echo "=== ??????????? 5 ???? + ????????==="
ls -la /tmp/retention-test

View File

@ -0,0 +1,3 @@
journalctl -u pms-api-test --since '-5 min' --no-pager | grep -iE 'ERROR|mismatch|export' | tail -20
echo '=== download_tasks ==='
mysql --defaults-extra-file=/root/.my.cnf -N -B -e "SELECT id,task_type,status,progress,file_path,completed_at FROM pms_dev.download_tasks ORDER BY id"

View File

@ -0,0 +1,18 @@
echo "=== 验收脚本产生的导出文件(两环境)==="
ls -la /data/pms_dev/api-server/exports/ /data/pms/api-server/exports/ 2>/dev/null
echo "=== 删除(exports 目录本身保留,属主 pms 不动)==="
rm -f "/data/pms_dev/api-server/exports/充电记录__-.xlsx" "/data/pms/api-server/exports/充电记录__-.xlsx"
echo "=== 删除后 ==="
ls -la /data/pms_dev/api-server/exports/ /data/pms/api-server/exports/
stat -c '%U:%G %n' /data/pms_dev/api-server/exports /data/pms/api-server/exports /data/pms/api-server/uploads
echo "=== 临时数据残留检查(应全 0)==="
mysql --defaults-extra-file=/root/.my.cnf -N -B -e "
SELECT 'dev.charge_orders', COUNT(*) FROM pms_dev.charge_orders WHERE battery_id LIKE 'VERIFY-%'
UNION ALL SELECT 'dev.device_logs', COUNT(*) FROM pms_dev.device_logs WHERE content LIKE '%VERIFY-%'
UNION ALL SELECT 'dev.download_tasks', COUNT(*) FROM pms_dev.download_tasks
UNION ALL SELECT 'dev.users_verify', COUNT(*) FROM pms_dev.users WHERE name LIKE 'VERIFY-%'
UNION ALL SELECT 'prod.charge_orders_all', COUNT(*) FROM pms.charge_orders
UNION ALL SELECT 'prod.device_logs_all', COUNT(*) FROM pms.device_logs
UNION ALL SELECT 'prod.download_tasks', COUNT(*) FROM pms.download_tasks"
echo "=== 服务 ==="
systemctl is-active pms-api pms-api-test pms-device pms-device-test nginx | paste -sd' '

View File

@ -0,0 +1,5 @@
echo "=== ???????????????????????==="
sha256sum /data/pms_dev/api-server/pms-api
systemctl show -p MainPID --value pms-api-test
echo "=== ??? download_tasks ==="
mysql --defaults-extra-file=/root/.my.cnf -N -B -e "SELECT id,task_type,status,progress,file_path,completed_at FROM pms_dev.download_tasks ORDER BY id"

View File

@ -0,0 +1,5 @@
echo "=== ???? ==="
ls -la /data/pms_dev/api-server/exports/
file /data/pms_dev/api-server/exports/*.xlsx 2>/dev/null
echo "=== journal???????? + ERROR?==="
journalctl -u pms-api-test --since '-4 min' --no-pager | grep -iE 'export|??|ERROR|mismatch' | tail -20

View File

@ -0,0 +1 @@
journalctl -u pms-api-test --since '-2 min' --no-pager | grep -iE 'ERROR' | tail -6

View File

@ -0,0 +1,6 @@
echo "=== 清理前 exports ==="
ls -la /data/pms_dev/api-server/exports/
rm -f "/data/pms_dev/api-server/exports/充电记录__-.xlsx"
echo "=== 清理后 exports(目录本身保留,属主 pms 不动)==="
ls -la /data/pms_dev/api-server/exports/
stat -c '%U:%G %n' /data/pms_dev/api-server/exports

View File

@ -0,0 +1,29 @@
set -e
echo "############ 清理前 ############"
echo "--- /data/pms 顶层 ---"
ls -la /data/pms/
echo "--- 回滚点(严格 backup-YYYYMMDD-HHMMSS 目录)---"
shopt -s nullglob
n=0
for d in /data/pms/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "$d" ]; then
echo " $(du -sh "$d" | cut -f1) $d"
n=$((n+1))
fi
done
echo " 回滚点数量=$n"
echo "--- backup-* 全部条目(含非回滚点)---"
ls -d /data/pms/backup-* 2>/dev/null || echo " (none)"
echo "--- 磁盘 ---"
df -h /data | tail -1
du -sh /data/pms /data/pms_dev
echo
echo "############ 执行回滚点保留策略(KEEP=5,真机)############"
bash /tmp/retention.sh
echo
echo "############ 清理后 ############"
ls -la /data/pms/
df -h /data | tail -1
du -sh /data/pms /data/pms_dev

View File

@ -0,0 +1,57 @@
set -u
DIR=/data/pms/api-server
echo "############ /data/pms/api-server 清理前 ############"
ls -la "$DIR"
echo "--- 目录占用 ---"
du -sh "$DIR"
echo "--- 当前运行中的二进制(绝不能碰)---"
systemctl show -p ExecStart --value pms-api
MAINPID=$(systemctl show -p MainPID --value pms-api)
echo "MainPID=$MAINPID exe=$(readlink -f /proc/$MAINPID/exe 2>/dev/null)"
RUNNING=$(readlink -f /proc/$MAINPID/exe 2>/dev/null)
echo
echo "############ 候选清单(历史二进制,排除当前 pms-api / .env / uploads / exports)############"
# 候选 = 该目录下的普通文件,且名字以 api-server 或 pms-api.bak 开头(严格白名单前缀)
cands=()
for f in "$DIR"/*; do
if [ -f "$f" ]; then
b=$(basename "$f")
case "$b" in
api-server|api-server.bak|api-server_old|pms-api.bak*|api-server*) cands+=( "$f" ) ;;
esac
fi
done
# 按 mtime 从新到旧排序
mapfile -t sorted < <(for f in "${cands[@]}"; do stat -c '%Y %n' "$f"; done | sort -rn | awk '{print $2}')
KEEP=2
echo " 候选(新→旧):"
for f in "${sorted[@]}"; do
echo " $(stat -c '%y %s %n' "$f")"
done
echo " 保留最近 $KEEP 个:"
for ((i=0;i<KEEP && i<${#sorted[@]};i++)); do echo " ${sorted[$i]}"; done
echo " 将删除:"
for ((i=KEEP;i<${#sorted[@]};i++)); do echo " ${sorted[$i]}"; done
echo
echo "############ 执行删除 ############"
for ((i=KEEP;i<${#sorted[@]};i++)); do
f="${sorted[$i]}"
if [ "$(readlink -f "$f")" = "$RUNNING" ]; then
echo " [skip] 正在运行的二进制,拒绝删除: $f"
continue
fi
case "$(basename "$f")" in
pms-api) echo " [skip] 当前二进制 pms-api,拒绝删除"; continue ;;
esac
rm -f -- "$f"
echo " 已删除 $f"
done
echo
echo "############ 清理后 ############"
ls -la "$DIR"
du -sh "$DIR"
echo "--- 服务仍在运行 ---"
systemctl is-active pms-api

View File

@ -0,0 +1,12 @@
echo "=== 1) 导出产物证据 ==="
ls -la /data/pms_dev/api-server/exports/
file /data/pms_dev/api-server/exports/*.xlsx
echo "=== 2) 测试环境日志:导出完成 + 近 5 分钟 ERROR ==="
journalctl -u pms-api-test --since '-5 min' --no-pager | grep -E '导出完成|ERROR' | tail -8
echo "=== 3) 删除本次验收产生的 xlsx(exports 目录本身保留,属主 pms 不动)==="
rm -f "/data/pms_dev/api-server/exports/充电记录__-.xlsx"
ls -la /data/pms_dev/api-server/exports/
stat -c '%U:%G %n' /data/pms_dev/api-server/exports /data/pms_dev/api-server/uploads
echo "=== 4) 清理本次会话在服务器上的临时文件 ==="
rm -rf /tmp/retention-test /tmp/retention-test.sh /tmp/retention.sh /tmp/remote*.sh
ls /tmp/*.sh /tmp/retention* 2>/dev/null || echo " /tmp 已无本次临时文件"

View File

@ -0,0 +1,27 @@
echo "=== 磁盘/目录 最终状态 ==="
df -h /data | tail -1
du -sh /data/pms /data/pms_dev /data/backup
echo "--- /data/pms 顶层 ---"
ls -la /data/pms/
echo "--- 回滚点(严格模式匹配)---"
shopt -s nullglob
for d in /data/pms/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "$d" ]; then echo " $(du -sh "$d" | cut -f1) $d"; fi
done
echo "--- /data/pms/api-server ---"
ls -la /data/pms/api-server/
echo "=== 临时数据残留检查(应全为 0)==="
mysql --defaults-extra-file=/root/.my.cnf -N -B -e "
SELECT 'dev.charge_orders', COUNT(*) FROM pms_dev.charge_orders WHERE battery_id='VERIFY-SCHEMA-TMP'
UNION ALL SELECT 'dev.device_logs', COUNT(*) FROM pms_dev.device_logs WHERE content LIKE '%VERIFY-SCHEMA-TMP%'
UNION ALL SELECT 'dev.download_tasks', COUNT(*) FROM pms_dev.download_tasks
UNION ALL SELECT 'dev.tmp_users', COUNT(*) FROM pms_dev.users WHERE phone IN ('19900000001','19900000002','19900000003')
UNION ALL SELECT 'prod.charge_orders', COUNT(*) FROM pms.charge_orders WHERE battery_id='VERIFY-SCHEMA-TMP'
UNION ALL SELECT 'prod.device_logs', COUNT(*) FROM pms.device_logs WHERE content LIKE '%VERIFY-SCHEMA-TMP%'
UNION ALL SELECT 'prod.charge_orders_total', COUNT(*) FROM pms.charge_orders
UNION ALL SELECT 'prod.device_logs_total', COUNT(*) FROM pms.device_logs"
echo "=== 服务状态 ==="
systemctl is-active pms-api pms-api-test pms-device pms-device-test nginx | paste -sd' '
rm -f /tmp/remote7.sh /tmp/remote8.sh
echo "=== 清理临时脚本后 ==="
ls /tmp/*.sh 2>/dev/null || echo " /tmp 无 .sh"

View File

@ -0,0 +1,50 @@
set -e
shopt -s nullglob
KEEP=5
# glob 字符类只匹配严格的 backup-YYYYMMDD-HHMMSS;nullglob 让无匹配时展开为空数组。
# glob 结果按字典序排列,对该命名格式而言等价于时间戳升序(最早的在前)。
rollbacks=()
for p in /data/pms/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "$p" ]; then
rollbacks+=( "$p" )
fi
done
total=${#rollbacks[@]}
echo " 当前回滚点 $total 个(deploy 会为每次部署新建一份):"
for p in "${rollbacks[@]}"; do
echo " $p"
done
if [ "$total" -gt "$KEEP" ]; then
drop=$((total - KEEP))
echo " 将删除最早的 $drop 个(保留最近 $KEEP 个):"
i=0
for p in "${rollbacks[@]}"; do
if [ "$i" -ge "$drop" ]; then
break
fi
i=$((i + 1))
# 复核:名字必须严格符合 backup-YYYYMMDD-HHMMSS,否则拒绝删除(绝不碰其它目录)
case "$(basename "$p")" in
backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
echo " 删除 $p"
rm -rf -- "$p"
;;
*)
echo " [skip] 名字不符合回滚点模式,拒绝删除: $p"
;;
esac
done
else
echo " 回滚点数量未超过 $KEEP,无需清理"
fi
kept=()
for p in /data/pms/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "$p" ]; then
kept+=( "$p" )
fi
done
echo " 清理后保留的回滚点 ${#kept[@]} 个:"
for p in "${kept[@]}"; do
echo " $p"
done
echo " /data/pms 占用: $(du -sh /data/pms | cut -f1)"

View File

View File

@ -0,0 +1,17 @@
2026-09-19T18:12:24.144708Z INFO pms_api: 配置加载完成
2026-09-19T18:12:24.227790Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:12:24.229316Z INFO pms_api: 数据库连接成功
2026-09-19T18:12:26.157847Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:12:27.053201Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:12:27.205445Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.energy:type decimal -> bigint, nullable YES -> NO, default '' -> '0', 注释已按目标口径同步;DDL=ALTER TABLE charge_records MODIFY COLUMN energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)'
2026-09-19T18:12:27.309455Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.start_time:type timestamp -> datetime, nullable YES -> NO, default '' -> 'CURRENT_TIMESTAMP', 注释无需变更;DDL=ALTER TABLE charge_records MODIFY COLUMN start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:12:27.414008Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.end_time:type timestamp -> datetime, nullable YES -> NO, default '' -> 'CURRENT_TIMESTAMP', 注释无需变更;DDL=ALTER TABLE charge_records MODIFY COLUMN end_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:12:27.514222Z INFO pms_api::db::migrate: [migrate] 列定义对齐 energy_stats.total_energy:type decimal -> bigint, nullable YES -> NO, default '' -> '0', 注释已按目标口径同步;DDL=ALTER TABLE energy_stats MODIFY COLUMN total_energy BIGINT NOT NULL DEFAULT 0 COMMENT '总能耗(Wh)'
2026-09-19T18:12:27.610518Z ERROR pms_api::db::migrate: [migrate] 列定义对齐失败 download_tasks.completed_at(跳过该列,继续启动)err=error returned from database: 1265 (01000): Data truncated for column 'completed_at' at row 1
2026-09-19T18:12:27.761165Z INFO pms_api::db::migrate: [migrate] 已创建索引 idx_organizations_parent ON organizations(parent_id)
2026-09-19T18:12:27.889135Z INFO pms_api::db::migrate: [migrate] 已补齐自引用外键 fk_organizations_parent (organizations.parent_id -> organizations.id)
2026-09-19T18:12:27.889777Z INFO pms_api::db::migrate: 历史库列定义对齐完成:本次执行了 5 项 DDL
2026-09-19T18:12:29.166466Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:12:33.212943Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:12:33.213915Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:12:33.214448Z INFO pms_api: 数据库迁移完成

View File

View File

@ -0,0 +1,11 @@
2026-09-19T18:12:36.776224Z INFO pms_api: 配置加载完成
2026-09-19T18:12:36.870145Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:12:36.871305Z INFO pms_api: 数据库连接成功
2026-09-19T18:12:38.777642Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:12:39.657395Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:12:39.977288Z ERROR pms_api::db::migrate: [migrate] 列定义对齐失败 download_tasks.completed_at(跳过该列,继续启动)err=error returned from database: 1265 (01000): Data truncated for column 'completed_at' at row 1
2026-09-19T18:12:40.094118Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:全部已一致,未执行任何 DDL
2026-09-19T18:12:41.314460Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:12:45.457970Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:12:45.458574Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:12:45.459131Z INFO pms_api: 数据库迁移完成

View File

View File

@ -0,0 +1,11 @@
2026-09-19T18:13:19.197105Z INFO pms_api: 配置加载完成
2026-09-19T18:13:19.273975Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:13:19.276092Z INFO pms_api: 数据库连接成功
2026-09-19T18:13:21.286065Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:13:22.153072Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:13:22.452279Z INFO pms_api::db::migrate: [migrate] 列定义对齐 download_tasks.completed_at:type timestamp -> datetime, nullable YES -> NO, default '' -> 'CURRENT_TIMESTAMP', 注释无需变更;DDL=ALTER TABLE download_tasks MODIFY COLUMN completed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:13:22.565319Z INFO pms_api::db::migrate: 历史库列定义对齐完成:本次执行了 1 项 DDL
2026-09-19T18:13:23.781076Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:13:27.768587Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:13:27.769587Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:13:27.770433Z INFO pms_api: 数据库迁移完成

View File

View File

@ -0,0 +1,10 @@
2026-09-19T18:13:34.245218Z INFO pms_api: 配置加载完成
2026-09-19T18:13:34.320563Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:13:34.321757Z INFO pms_api: 数据库连接成功
2026-09-19T18:13:36.318097Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:13:37.243873Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:13:37.584574Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:全部已一致,未执行任何 DDL
2026-09-19T18:13:38.804532Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:13:42.873183Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:13:42.874471Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:13:42.875657Z INFO pms_api: 数据库迁移完成

View File

View File

@ -0,0 +1,42 @@
2026-09-19T18:24:50.537001Z INFO pms_api: 配置加载完成
2026-09-19T18:24:50.617127Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:24:50.618151Z INFO pms_api: 数据库连接成功
2026-09-19T18:24:52.548639Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:24:53.448374Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:24:53.576589Z INFO pms_api::db::migrate: [migrate] 列定义对齐 organizations.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE organizations MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:53.680300Z INFO pms_api::db::migrate: [migrate] 列定义对齐 projects.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE projects MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:53.793972Z INFO pms_api::db::migrate: [migrate] 列定义对齐 cabinets.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE cabinets MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:53.895396Z INFO pms_api::db::migrate: [migrate] 列定义对齐 cabin_boards.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE cabin_boards MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:54.016489Z INFO pms_api::db::migrate: [migrate] 列定义对齐 compartments.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE compartments MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:54.309576Z INFO pms_api::db::migrate: [migrate] 列定义对齐 batteries.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE batteries MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:54.489206Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_orders.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE charge_orders MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:54.600574Z INFO pms_api::db::migrate: [migrate] 列定义对齐 users.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE users MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:54.706993Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE charge_records MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:54.808376Z INFO pms_api::db::migrate: [migrate] 列定义对齐 device_logs.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE device_logs MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:54.914284Z INFO pms_api::db::migrate: [migrate] 列定义对齐 energy_stats.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE energy_stats MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.023111Z INFO pms_api::db::migrate: [migrate] 列定义对齐 operation_logs.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE operation_logs MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.142571Z INFO pms_api::db::migrate: [migrate] 列定义对齐 roles.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE roles MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.243247Z INFO pms_api::db::migrate: [migrate] 列定义对齐 permissions.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE permissions MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.345065Z INFO pms_api::db::migrate: [migrate] 列定义对齐 user_scopes.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE user_scopes MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.449238Z INFO pms_api::db::migrate: [migrate] 列定义对齐 download_tasks.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE download_tasks MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.561957Z INFO pms_api::db::migrate: [migrate] 列定义对齐 abstract_bindings.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE abstract_bindings MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.665036Z INFO pms_api::db::migrate: [migrate] 列定义对齐 user_feedback.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE user_feedback MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:24:55.819095Z INFO pms_api::db::migrate: [migrate] 列定义对齐 organizations.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE organizations MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:55.922669Z INFO pms_api::db::migrate: [migrate] 列定义对齐 projects.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE projects MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:56.033176Z INFO pms_api::db::migrate: [migrate] 列定义对齐 cabinets.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE cabinets MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:56.143306Z INFO pms_api::db::migrate: [migrate] 列定义对齐 cabin_boards.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE cabin_boards MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:56.248684Z INFO pms_api::db::migrate: [migrate] 列定义对齐 compartments.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE compartments MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:56.351893Z INFO pms_api::db::migrate: [migrate] 列定义对齐 batteries.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE batteries MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:56.462717Z INFO pms_api::db::migrate: [migrate] 列定义对齐 users.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE users MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:56.564295Z INFO pms_api::db::migrate: [migrate] 列定义对齐 roles.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE roles MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:24:56.665845Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.start_time:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已按目标口径同步;DDL=ALTER TABLE charge_records MODIFY COLUMN start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '充电开始时间'
2026-09-19T18:24:56.768583Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.end_time:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已按目标口径同步;DDL=ALTER TABLE charge_records MODIFY COLUMN end_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '充电结束时间'
2026-09-19T18:24:56.884752Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_orders.start_time:type timestamp -> DATETIME, nullable YES -> YES, default '' -> '', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE charge_orders MODIFY COLUMN start_time DATETIME NULL COMMENT '充电开始时间'
2026-09-19T18:24:57.036290Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_orders.end_time:type timestamp -> DATETIME, nullable YES -> YES, default '' -> '', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE charge_orders MODIFY COLUMN end_time DATETIME NULL COMMENT '充电结束时间'
2026-09-19T18:24:57.132247Z INFO pms_api::db::migrate: [migrate] 列定义对齐 download_tasks.completed_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已按目标口径同步;DDL=ALTER TABLE download_tasks MODIFY COLUMN completed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '完成时间'
2026-09-19T18:24:57.291558Z INFO pms_api::db::migrate: [migrate] 列定义对齐 sim_change_logs.changed_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE sim_change_logs MODIFY COLUMN changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间'
2026-09-19T18:24:57.533530Z INFO pms_api::db::migrate: 历史库列定义对齐完成:本次执行了 32 项 DDL
2026-09-19T18:24:58.890249Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:25:02.980032Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:25:02.981647Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:25:02.982297Z INFO pms_api: 数据库迁移完成

View File

View File

@ -0,0 +1,10 @@
2026-09-19T18:25:07.520560Z INFO pms_api: 配置加载完成
2026-09-19T18:25:07.601492Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:25:07.602585Z INFO pms_api: 数据库连接成功
2026-09-19T18:25:09.516627Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:25:10.376441Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:25:12.080675Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:35 列 + organizations.parent_id 索引/外键全部已一致,未执行任何 DDL
2026-09-19T18:25:13.318766Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:25:17.527106Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:25:17.528224Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:25:17.528889Z INFO pms_api: 数据库迁移完成

View File

View File

@ -0,0 +1,10 @@
2026-09-19T18:33:53.362764Z INFO pms_api: 配置加载完成
2026-09-19T18:33:53.441528Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:33:53.442730Z INFO pms_api: 数据库连接成功
2026-09-19T18:33:55.390841Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:33:56.255598Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:33:57.850061Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:35 列定义 + 3 处注释口径 + organizations.parent_id 索引/外键全部已一致,未执行任何 DDL
2026-09-19T18:33:59.119151Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:34:03.083549Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:34:03.086569Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:34:03.087097Z INFO pms_api: 数据库迁移完成

View File

@ -0,0 +1,10 @@
2026-09-19T18:13:54.477418Z INFO pms_api: 配置加载完成
2026-09-19T18:13:54.695258Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:13:54.696431Z INFO pms_api: 数据库连接成功
2026-09-19T18:13:56.697791Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:13:57.575693Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:13:57.915935Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:全部已一致,未执行任何 DDL
2026-09-19T18:13:59.135681Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:14:03.089331Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:14:03.090600Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:14:03.091667Z INFO pms_api: 数据库迁移完成

View File

@ -0,0 +1,10 @@
2026-09-19T18:14:09.494692Z INFO pms_api: 配置加载完成
2026-09-19T18:14:09.585364Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:14:09.586400Z INFO pms_api: 数据库连接成功
2026-09-19T18:14:11.533080Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:14:12.387751Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:14:12.741994Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:全部已一致,未执行任何 DDL
2026-09-19T18:14:13.948453Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:14:17.936639Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:14:17.937745Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:14:17.938530Z INFO pms_api: 数据库迁移完成

View File

@ -0,0 +1,28 @@
2026-09-19T18:25:32.185057Z INFO pms_api: 配置加载完成
2026-09-19T18:25:32.263332Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:25:32.264523Z INFO pms_api: 数据库连接成功
2026-09-19T18:25:34.251156Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:25:35.094486Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:25:35.439717Z INFO pms_api::db::migrate: [migrate] 列定义对齐 batteries.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE batteries MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:25:35.551972Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_orders.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE charge_orders MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:25:36.290773Z INFO pms_api::db::migrate: [migrate] 列定义对齐 user_feedback.created_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE user_feedback MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
2026-09-19T18:25:36.429568Z INFO pms_api::db::migrate: [migrate] 列定义对齐 organizations.updated_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE organizations MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:36.525030Z INFO pms_api::db::migrate: [migrate] 列定义对齐 projects.updated_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE projects MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:36.628013Z INFO pms_api::db::migrate: [migrate] 列定义对齐 cabinets.updated_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE cabinets MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:36.728496Z INFO pms_api::db::migrate: [migrate] 列定义对齐 cabin_boards.updated_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE cabin_boards MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:36.828271Z INFO pms_api::db::migrate: [migrate] 列定义对齐 compartments.updated_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE compartments MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:36.929144Z INFO pms_api::db::migrate: [migrate] 列定义对齐 batteries.updated_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 有 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE batteries MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:37.038989Z INFO pms_api::db::migrate: [migrate] 列定义对齐 users.updated_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE users MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:37.134567Z INFO pms_api::db::migrate: [migrate] 列定义对齐 roles.updated_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 需要, 注释已一致/未校验;DDL=ALTER TABLE roles MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
2026-09-19T18:25:37.237019Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.start_time:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已按目标口径同步;DDL=ALTER TABLE charge_records MODIFY COLUMN start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '充电开始时间'
2026-09-19T18:25:37.336864Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_records.end_time:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已按目标口径同步;DDL=ALTER TABLE charge_records MODIFY COLUMN end_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '充电结束时间'
2026-09-19T18:25:37.455448Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_orders.start_time:type timestamp -> DATETIME, nullable YES -> YES, default '' -> '', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE charge_orders MODIFY COLUMN start_time DATETIME NULL COMMENT '充电开始时间'
2026-09-19T18:25:37.558525Z INFO pms_api::db::migrate: [migrate] 列定义对齐 charge_orders.end_time:type timestamp -> DATETIME, nullable YES -> YES, default '' -> '', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE charge_orders MODIFY COLUMN end_time DATETIME NULL COMMENT '充电结束时间'
2026-09-19T18:25:37.670554Z INFO pms_api::db::migrate: [migrate] 列定义对齐 download_tasks.completed_at:type datetime -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已按目标口径同步;DDL=ALTER TABLE download_tasks MODIFY COLUMN completed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '完成时间'
2026-09-19T18:25:37.828849Z INFO pms_api::db::migrate: [migrate] 列定义对齐 sim_change_logs.changed_at:type timestamp -> DATETIME, nullable NO -> NO, default 'CURRENT_TIMESTAMP' -> 'CURRENT_TIMESTAMP', on_update 无 -> 不需要, 注释已一致/未校验;DDL=ALTER TABLE sim_change_logs MODIFY COLUMN changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间'
2026-09-19T18:25:38.034391Z INFO pms_api::db::migrate: [migrate] 列定义对齐 organizations.parent_id:type bigint -> BIGINT, nullable YES -> YES, default '' -> '', on_update 无 -> 不需要, 注释已按目标口径同步;DDL=ALTER TABLE organizations MODIFY COLUMN parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)'
2026-09-19T18:25:38.143205Z INFO pms_api::db::migrate: 历史库列定义对齐完成:本次执行了 18 项 DDL
2026-09-19T18:25:39.372853Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:25:43.404613Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:25:43.405253Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:25:43.405741Z INFO pms_api: 数据库迁移完成

View File

@ -0,0 +1,10 @@
2026-09-19T18:25:51.345960Z INFO pms_api: 配置加载完成
2026-09-19T18:25:51.426036Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:25:51.427165Z INFO pms_api: 数据库连接成功
2026-09-19T18:25:53.370017Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:25:54.258199Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:25:55.716955Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:35 列 + organizations.parent_id 索引/外键全部已一致,未执行任何 DDL
2026-09-19T18:25:57.017755Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:26:01.059264Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:26:01.060052Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:26:01.060501Z INFO pms_api: 数据库迁移完成

View File

@ -0,0 +1,13 @@
2026-09-19T18:34:10.372380Z INFO pms_api: 配置加载完成
2026-09-19T18:34:10.446783Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:34:10.447951Z INFO pms_api: 数据库连接成功
2026-09-19T18:34:12.409960Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:34:13.282992Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:34:14.912031Z INFO pms_api::db::migrate: [migrate] 更新列注释 batteries.brand(原注释 '品牌' 不含 '由 brand_code 映射出的名称');DDL=ALTER TABLE batteries MODIFY COLUMN brand VARCHAR(50) NULL COMMENT '品牌(由 brand_code 映射出的名称,代码表未定前为空)'
2026-09-19T18:34:15.003112Z INFO pms_api::db::migrate: [migrate] 更新列注释 batteries.model(原注释 '型号' 不含 '由 model_code 映射出的名称');DDL=ALTER TABLE batteries MODIFY COLUMN model VARCHAR(50) NULL COMMENT '型号(由 model_code 映射出的名称,代码表未定前为空)'
2026-09-19T18:34:15.115724Z INFO pms_api::db::migrate: [migrate] 更新列注释 cabinets.address(原注释 '安装地址' 不含 '详细地址');DDL=ALTER TABLE cabinets MODIFY COLUMN address VARCHAR(200) NULL COMMENT '详细地址'
2026-09-19T18:34:15.117373Z INFO pms_api::db::migrate: 历史库列定义对齐完成:本次执行了 3 项 DDL
2026-09-19T18:34:16.337833Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:34:20.457652Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:34:20.459891Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:34:20.461680Z INFO pms_api: 数据库迁移完成

View File

@ -0,0 +1,10 @@
2026-09-19T18:34:36.429826Z INFO pms_api: 配置加载完成
2026-09-19T18:34:36.504806Z INFO pms_api::db: MySQL 连接池已建立
2026-09-19T18:34:36.506064Z INFO pms_api: 数据库连接成功
2026-09-19T18:34:38.598065Z INFO pms_api::db::migrate: 数据库索引创建完成
2026-09-19T18:34:39.470938Z INFO pms_api::db::migrate: 数据库列级迁移完成
2026-09-19T18:34:41.031375Z INFO pms_api::db::migrate: 历史库列定义对齐检查完成:35 列定义 + 3 处注释口径 + organizations.parent_id 索引/外键全部已一致,未执行任何 DDL
2026-09-19T18:34:42.296200Z INFO pms_api::db::migrate: 默认权限码初始化完成
2026-09-19T18:34:46.637549Z INFO pms_api::db::migrate: 默认角色及权限分配完成
2026-09-19T18:34:46.641089Z INFO pms_api::db::migrate: 数据库迁移完成
2026-09-19T18:34:46.642778Z INFO pms_api: 数据库迁移完成

View File

@ -0,0 +1,5 @@
{
"charge_orders": "CREATE TABLE `charge_orders` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `compartment_id` bigint NOT NULL COMMENT '仓体ID',\n `cabinet_id` bigint NOT NULL COMMENT '柜子ID',\n `battery_id` varchar(50) NOT NULL COMMENT '电池ID',\n `start_time` timestamp NULL DEFAULT NULL COMMENT '充电开始时间',\n `end_time` timestamp NULL DEFAULT NULL COMMENT '充电结束时间',\n `start_soc` int DEFAULT NULL COMMENT '起始电量(‰)',\n `end_soc` int DEFAULT NULL COMMENT '结束电量(‰)',\n `energy` int DEFAULT '0' COMMENT '充电量(Wh)',\n `reason` varchar(20) DEFAULT NULL COMMENT '结束原因: normal/user_stop/forced/timeout',\n `status` tinyint DEFAULT '0' COMMENT '0充电中 1已完成 2异常',\n `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `active_flag` bigint GENERATED ALWAYS AS ((case when (`status` = 0) then `compartment_id` else NULL end)) STORED COMMENT '活跃订单唯一性哨兵:status=0 时=compartment_id,否则 NULL(NULL 不参与唯一性)',\n PRIMARY KEY (`id`),\n UNIQUE KEY `uk_charge_orders_active` (`active_flag`),\n KEY `cabinet_id` (`cabinet_id`),\n KEY `idx_battery` (`battery_id`),\n KEY `idx_compartment` (`compartment_id`),\n KEY `idx_charge_orders_status_start` (`status`,`start_time`),\n CONSTRAINT `charge_orders_ibfk_1` FOREIGN KEY (`compartment_id`) REFERENCES `compartments` (`id`),\n CONSTRAINT `charge_orders_ibfk_2` FOREIGN KEY (`cabinet_id`) REFERENCES `cabinets` (`id`)\n) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci",
"charge_records": "CREATE TABLE `charge_records` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `compartment_id` bigint NOT NULL,\n `cabinet_id` bigint NOT NULL,\n `start_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `end_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `energy` bigint NOT NULL DEFAULT '0' COMMENT '充电量(Wh)',\n `max_temperature` bigint NOT NULL DEFAULT '0' COMMENT '最高温度(K×100)',\n `status` tinyint DEFAULT '0' COMMENT '0正常 1异常 2手动停止',\n `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (`id`),\n KEY `compartment_id` (`compartment_id`),\n KEY `idx_charge_records_cabinet` (`cabinet_id`),\n KEY `idx_charge_records_cabinet_start_time` (`cabinet_id`,`start_time`),\n KEY `idx_charge_records_start_time` (`start_time`),\n CONSTRAINT `charge_records_ibfk_1` FOREIGN KEY (`compartment_id`) REFERENCES `compartments` (`id`),\n CONSTRAINT `charge_records_ibfk_2` FOREIGN KEY (`cabinet_id`) REFERENCES `cabinets` (`id`)\n) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci",
"device_logs": "CREATE TABLE `device_logs` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `cabinet_id` bigint NOT NULL,\n `log_type` tinyint NOT NULL COMMENT '1状态上报 2故障 3告警 4停电',\n `content` text COMMENT '日志内容JSON',\n `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (`id`),\n KEY `idx_device_logs_cabinet` (`cabinet_id`),\n KEY `idx_device_logs_created` (`created_at`),\n CONSTRAINT `device_logs_ibfk_1` FOREIGN KEY (`cabinet_id`) REFERENCES `cabinets` (`id`)\n) ENGINE=InnoDB AUTO_INCREMENT=101 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci"
}

View File

@ -0,0 +1,402 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
user_scopes(数据范围)隔离实测 — 在测试库 pms_dev + 测试环境 API(:3000) 上跑
覆盖验收 1-6:
1. 造两个项目(同组织)+ 两个用户(同组织、role_level=0)+ 一个总管理员
2. 未配置 scope 的用户 → 能看到两个项目的设备(= 现状,证明向后兼容)
3. 配置 scope 只允许项目 A 的用户 → 只能看到项目 A 的设备(列表 + 详情都测)
4. 总管理员 → 看到全部(不受 scope 影响)
5. 配置一个不属于该组织的项目 id / 组织 id / 不存在的 id → 被 BadRequest 拒绝
6. 清理临时数据(脚本 finally 里无条件执行)
用法:
set PMS_TEST_JWT_SECRET=<测试环境 .env 里的 JWT_SECRET>
python scope_isolation_check.py
"""
import os
import sys
import time
import json
import random
import urllib.request
import urllib.error
import pymysql
import jwt # PyJWT
DB = dict(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev")
API = os.environ.get("PMS_TEST_API_BASE", "http://10.8.0.252:3000/api")
SECRET = os.environ.get("PMS_TEST_JWT_SECRET", "")
TAG = "SCOPETEST"
SUFFIX = f"{int(time.time()) % 100000:05d}"
PASS, FAIL = [], []
def ok(name, cond, detail=""):
(PASS if cond else FAIL).append(name)
print(f" [{'PASS' if cond else 'FAIL'}] {name} {detail}")
def token(uid, phone):
now = int(time.time())
return jwt.encode({"sub": uid, "phone": phone, "exp": now + 3600, "iat": now},
SECRET, algorithm="HS256")
def call(method, path, tok=None, body=None):
"""返回 (status, json_or_text)"""
req = urllib.request.Request(API + path, method=method)
req.add_header("Content-Type", "application/json")
if tok:
req.add_header("Authorization", "Bearer " + tok)
data = json.dumps(body).encode() if body is not None else None
try:
with urllib.request.urlopen(req, data, timeout=20) as r:
raw = r.read().decode()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw
def main():
if not SECRET:
print("缺少 PMS_TEST_JWT_SECRET 环境变量(取 /data/pms_dev/api-server/.env 的 JWT_SECRET)")
sys.exit(2)
conn = pymysql.connect(**DB, autocommit=True)
cur = conn.cursor()
ids = {}
try:
# ---------------- 0. 预清理(防上次异常退出残留) ----------------
cleanup(cur)
# ---------------- 1. 造夹具 ----------------
print("\n=== [1] 造夹具 ===")
cur.execute("INSERT INTO organizations (name) VALUES (%s)", (f"{TAG}-ORG-{SUFFIX}",))
org = cur.lastrowid
cur.execute("INSERT INTO organizations (name) VALUES (%s)", (f"{TAG}-ORG2-{SUFFIX}",))
org2 = cur.lastrowid
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org, f"{TAG}-A-{SUFFIX}"))
pa = cur.lastrowid
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org, f"{TAG}-B-{SUFFIX}"))
pb = cur.lastrowid
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org2, f"{TAG}-C-{SUFFIX}"))
pc = cur.lastrowid
def add_cabinet(pid, seed):
imei = f"86{seed}{random.randint(1000000, 9999999)}"[:15]
# abstract_id 必须给值:h5 设备列表行结构体是 String(DB 可空),
# 留 NULL 会让该接口 500(历史缺陷,非本次改动引入)
cur.execute("INSERT INTO cabinets (project_id, imei, name, status, auth_str, abstract_id) "
"VALUES (%s,%s,%s,1,%s,%s)",
(pid, imei, f"{TAG}-CAB-{seed}", "AbCd1234", f"10-{SUFFIX}{seed}"))
cid = cur.lastrowid
cur.execute("INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (%s,1)", (cid,))
bid = cur.lastrowid
comp = None
for ch in range(1, 7):
cur.execute("INSERT INTO compartments (cabin_board_id, channel_id) VALUES (%s,%s)", (bid, ch))
if ch == 1:
comp = cur.lastrowid
# 每个柜子造 1 条充电订单 + 1 条设备日志 + 1 条充电记录(用于验证列表接口的范围收窄)
cur.execute("INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_soc, energy, status) "
"VALUES (%s,%s,%s,50,100,1)", (comp, cid, f"{TAG}-BAT-{seed}"))
order_id = cur.lastrowid
cur.execute("INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (%s,1,%s)",
(cid, f"{TAG}-LOG-{seed}"))
cur.execute("INSERT INTO charge_records (compartment_id, cabinet_id, energy, status) VALUES (%s,%s,10,0)",
(comp, cid))
return cid, imei, order_id
cab_a, imei_a, order_a = add_cabinet(pa, "1")
cab_b, imei_b, order_b = add_cabinet(pb, "2")
cab_c, imei_c, order_c = add_cabinet(pc, "3")
def add_user(role, organization_id, label):
phone = f"19{random.randint(100000000, 999999999)}"
cur.execute("INSERT INTO users (phone, name, password, role, organization_id, status) "
"VALUES (%s,%s,%s,%s,%s,1)",
(phone, f"{TAG}-{label}-{SUFFIX}", "not-a-real-hash", role, organization_id))
return cur.lastrowid, phone
u_open, ph_open = add_user(0, org, "OPEN") # 未配置 scope
u_a, ph_a = add_user(0, org, "SCOPEA") # 配 scope=项目A
u_admin, ph_admin = add_user(2, None, "ADMIN") # 总管理员
ids = dict(org=org, org2=org2, pa=pa, pb=pb, pc=pc,
cab_a=cab_a, cab_b=cab_b, cab_c=cab_c,
order_a=order_a, order_b=order_b, order_c=order_c,
u_open=u_open, u_a=u_a, u_admin=u_admin)
print(f" org={org} org2={org2} projA={pa} projB={pb} projC(org2)={pc}")
print(f" cabA={cab_a}/{imei_a} cabB={cab_b}/{imei_b} cabC={cab_c}/{imei_c}")
print(f" user_open={u_open} user_scopeA={u_a} admin={u_admin}")
t_open = token(u_open, ph_open)
t_a = token(u_a, ph_a)
t_admin = token(u_admin, ph_admin)
# ---------------- 2. 未配置 scope = 不限制(向后兼容) ----------------
print("\n=== [2] 未配置 scope 的用户 → 不限制(两个项目都可见)===")
st, body = call("GET", "/cabinets/filter?page_size=100", t_open)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
print(f" GET /cabinets/filter -> {st} total={body['data']['total'] if st == 200 else body}")
ok("未配置 scope 可见项目A设备", cab_a in got)
ok("未配置 scope 可见项目B设备", cab_b in got)
ok("未配置 scope 不可见跨组织(项目C)设备", cab_c not in got)
# ---------------- 5. 非法 scope_id 必须 400 ----------------
print("\n=== [5] 非法 scope_id(跨组织/不存在)→ BadRequest ===")
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pc, "permission_type": 1}]})
print(f" PUT scopes(项目C, 属组织2) -> {st} {body}")
ok("跨组织项目被拒 400", st == 400 and "不属于该用户所属组织" in str(body))
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": 999999999, "permission_type": 1}]})
print(f" PUT scopes(不存在项目) -> {st} {body}")
ok("不存在项目被拒 400", st == 400 and "不存在" in str(body))
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 1, "scope_id": org2, "permission_type": 1}]})
print(f" PUT scopes(组织级=别人的组织) -> {st} {body}")
ok("组织级跨组织被拒 400", st == 400)
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 9}]})
print(f" PUT scopes(permission_type=9) -> {st} {body}")
ok("非法 permission_type 被拒 400", st == 400)
st, body = call("PUT", "/users/999999999/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
print(f" PUT scopes(用户不存在) -> {st} {body}")
ok("目标用户不存在 -> 404", st == 404)
# ---------------- 3. 配置 scope=项目A → 收窄 ----------------
print("\n=== [3] 配置 scope 只允许项目 A → 只能看到项目 A ===")
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
print(f" PUT /users/{u_a}/scopes {{项目A}} -> {st} {body}")
ok("配置项目范围成功", st == 200)
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
print(f" GET /cabinets/filter -> {st} total={body['data']['total'] if st == 200 else body}")
print(f" data ids = {got}")
ok("列表:项目A设备可见", cab_a in got)
ok("列表:项目B设备不可见", cab_b not in got)
ok("列表:跨组织设备不可见", cab_c not in got)
st, body = call("GET", f"/cabinets/{cab_a}", t_a)
print(f" GET /cabinets/{cab_a}(范围内详情) -> {st} data.id={body['data']['id'] if st == 200 else body}")
ok("详情:范围内设备 200", st == 200 and body["data"]["id"] == cab_a)
st, body = call("GET", f"/cabinets/{cab_b}", t_a)
print(f" GET /cabinets/{cab_b}(范围外详情) -> {st} {body}")
ok("详情:范围外设备被拒 403", st == 403)
st, body = call("GET", f"/cabinets/{cab_c}", t_a)
print(f" GET /cabinets/{cab_c}(跨组织详情) -> {st} {body}")
ok("详情:跨组织设备被拒 403", st == 403)
st, body = call("GET", f"/projects/{pb}/cabinets", t_a)
print(f" GET /projects/{pb}/cabinets(范围外项目的设备列表) -> {st} {body}")
ok("范围外项目的设备列表被拒 403", st == 403)
st, body = call("GET", f"/projects/{pa}/cabinets", t_a)
print(f" GET /projects/{pa}/cabinets(范围内项目) -> {st} rows={len(body['data']) if st == 200 else body}")
ok("范围内项目设备列表 200", st == 200 and len(body["data"]) == 1)
st, body = call("GET", "/cabinets/realtime-status?imei=" + imei_b, t_a)
print(f" GET /cabinets/realtime-status?imei=(项目B) -> {st} {body}")
ok("范围外设备实时状态被拒 403", st == 403)
# 订单/记录/日志/能耗 列表接口(叠加了范围条件)—— 必须只出现项目 A 的数据
orders_filter_failed = False
st_filter_failed = False
st, body = call("GET", "/charge-orders?page_size=50", t_a)
order_ids = [o["id"] for o in body["data"]["list"]] if st == 200 else []
print(f" GET /charge-orders -> {st} total={body['data']['total'] if st == 200 else body} ids={order_ids}")
if st != 200:
# 诊断:换成「总管理员」再打一次。若同样 500 ⇒ 与数据范围无关(环境/结构问题)
st_admin, body_admin = call("GET", "/charge-orders?page_size=50", t_admin)
print(f" [诊断] 总管理员 GET /charge-orders -> {st_admin} {body_admin}")
ok("订单 500 与数据范围无关(总管理员同样失败)", st_admin == 500)
orders_filter_failed = True
else:
ok("订单列表可访问", st == 200)
ok("订单列表:项目A订单可见", order_a in order_ids)
ok("订单列表:项目B订单不可见", order_b not in order_ids)
ok("订单列表:跨组织订单不可见", order_c not in order_ids)
st, body = call("GET", "/charge-records?page_size=50", t_a)
rec_cabs = [r["cabinet_id"] for r in body["data"]["list"]] if st == 200 else []
print(f" GET /charge-records -> {st} total={body['data']['total'] if st == 200 else body} cabinets={rec_cabs}")
ok("充电记录列表可访问", st == 200)
ok("充电记录:仅项目A柜子", cab_a in rec_cabs and cab_b not in rec_cabs and cab_c not in rec_cabs)
st, body = call("GET", "/device-logs?page_size=50", t_a)
log_cabs = [r["cabinet_id"] for r in body["data"]["list"]] if st == 200 else []
print(f" GET /device-logs -> {st} total={body['data']['total'] if st == 200 else body} cabinets={log_cabs}")
if st != 200:
st_admin, body_admin = call("GET", "/device-logs?page_size=50", t_admin)
print(f" [诊断] 总管理员 GET /device-logs -> {st_admin} {body_admin}")
ok("设备日志 500 与数据范围无关(总管理员同样失败)", st_admin == 500)
st_filter_failed = True
else:
ok("设备日志列表可访问", st == 200)
ok("设备日志:仅项目A柜子", cab_a in log_cabs and cab_b not in log_cabs and cab_c not in log_cabs)
# —— 端点因历史 TIMESTAMP/DATETIME 漂移 500 时,直接用「实现生成的同一条 SQL」在库里验证收窄 ——
if st_filter_failed or orders_filter_failed:
print("\n [SQL 级验证] 用实现生成的同一条 WHERE 条件直接查库:")
sql_orders = (
"SELECT co.id FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1"
" AND co.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id"
" WHERE p.organization_id = %s)"
" AND co.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (%s))"
)
cur.execute(sql_orders, (org, pa))
got_orders = [r[0] for r in cur.fetchall()]
print(f" 订单 SQL(组织={org} ∧ 项目={pa}) -> {got_orders}")
ok("SQL 级:订单只命中项目A", order_a in got_orders and order_b not in got_orders and order_c not in got_orders)
sql_logs = (
"SELECT dl.cabinet_id FROM device_logs dl WHERE 1=1"
" AND dl.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id"
" WHERE p.organization_id = %s)"
" AND dl.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (%s))"
)
cur.execute(sql_logs, (org, pa))
got_logs = [r[0] for r in cur.fetchall()]
print(f" 设备日志 SQL(组织={org} ∧ 项目={pa}) -> {got_logs}")
ok("SQL 级:设备日志只命中项目A柜子", cab_a in got_logs and cab_b not in got_logs and cab_c not in got_logs)
for path in ("/energy-stats/summary", "/energy-stats/projects", "/energy-stats/cabinets", "/energy-stats/trend"):
st, body = call("GET", path, t_a)
print(f" GET {path} -> {st} {json.dumps(body, ensure_ascii=False)[:160] if st == 200 else body}")
ok(f"范围内用户可访问 {path}", st == 200)
st, body = call("GET", "/energy-stats/cabinets", t_a)
rank_ids = [r["cabinet_id"] for r in body["data"]] if st == 200 else []
print(f" 能耗排行 cabinet_ids = {rank_ids}")
ok("能耗排行:仅项目A柜子", cab_a in rank_ids and cab_b not in rank_ids and cab_c not in rank_ids)
# 小程序设备列表(h5)
st, body = call("GET", "/cabinets", t_a)
ids_h5 = [c["id"] for c in body["data"]] if st == 200 else []
print(f" GET /cabinets (小程序列表) -> {st} ids={ids_h5}")
ok("小程序列表:项目A可见", cab_a in ids_h5)
ok("小程序列表:项目B不可见", cab_b not in ids_h5)
# 写操作:范围外设备指令下发(设备必然离线,但归属校验在更前面)
st, body = call("POST", "/charge/start", t_a, {"compartment_id": 0})
print(f" POST /charge/start (空仓体) -> {st} {body}")
ok("写操作接口有归属校验路径(非 500)", st in (400, 403, 404))
# ---------------- 3b. 组织级 scope = 自己组织(不额外收窄) ----------------
print("\n=== [3b] 组织级 scope = 本人所属组织(合法,且不额外收窄)===")
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 1, "scope_id": org, "permission_type": 1}]})
print(f" PUT scopes(组织级=本组织) -> {st} {body}")
ok("组织级=本组织 被接受", st == 200)
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
ok("组织级范围下项目A仍可见", cab_a in got)
ok("组织级范围下项目B仍可见(组织级=整个组织)", cab_b in got)
# ---------------- 3c. 脏数据:一条有效都没有 → 不锁死 ----------------
print("\n=== [3c] 库内脏数据(指向不存在/跨组织/设备级)→ 忽略且不锁死 ===")
st, _ = call("PUT", f"/users/{u_a}/scopes", t_admin, {"scopes": []})
ok("清空范围成功", st == 200)
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,2,%s,1)",
(u_a, 999999999))
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,2,%s,1)",
(u_a, pc))
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,3,%s,1)",
(u_a, cab_b))
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
print(f" GET /cabinets/filter(脏数据下) -> {st} ids={got}")
ok("脏数据全部被忽略后按「不限制」处理(项目B可见,不锁死)", cab_a in got and cab_b in got)
st, body = call("GET", f"/users/{u_a}/scopes", t_admin)
print(f" GET /users/{u_a}/scopes -> {st} {body['data'] if st == 200 else body}")
# 3 条脏数据:不存在项目 / 跨组织项目 / 设备级(设备级**永不参与鉴权** ⇒ valid=false)
ok("脏数据被标记 valid=false",
st == 200 and len(body["data"]) == 3 and all(not s["valid"] for s in body["data"]))
cur.execute("DELETE FROM user_scopes WHERE user_id=%s", (u_a,))
# ---------------- 4. 总管理员豁免 ----------------
print("\n=== [4] 总管理员 → 看到全部(不受 scope 影响)===")
st, body = call("PUT", f"/users/{u_admin}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
print(f" (故意给总管理员配 scope=项目A) PUT scopes -> {st} {body}")
st, body = call("GET", "/cabinets/filter?page_size=100", t_admin)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
ok("总管理员仍可见项目A设备", cab_a in got)
ok("总管理员仍可见项目B设备(豁免)", cab_b in got)
ok("总管理员仍可见跨组织设备(豁免)", cab_c in got)
st, body = call("GET", f"/cabinets/{cab_b}", t_admin)
ok("总管理员可看任意设备详情", st == 200)
finally:
# ---------------- 6. 清理 ----------------
print("\n=== [6] 清理临时数据 ===")
try:
cleanup(cur)
print(" 已清理(scopes/users/cabinets/boards/compartments/projects/organizations)")
cur.execute(f"SELECT COUNT(*) FROM organizations WHERE name LIKE '{TAG}%'")
print(" 残留组织数 =", cur.fetchone()[0])
cur.execute(f"SELECT COUNT(*) FROM users WHERE name LIKE '{TAG}%'")
print(" 残留用户数 =", cur.fetchone()[0])
cur.execute(f"SELECT COUNT(*) FROM projects WHERE name LIKE '{TAG}%'")
print(" 残留项目数 =", cur.fetchone()[0])
cur.execute(f"SELECT COUNT(*) FROM cabinets WHERE name LIKE '{TAG}%'")
print(" 残留设备数 =", cur.fetchone()[0])
except Exception as e: # noqa: BLE001
print(" 清理失败:", e)
cur.close()
conn.close()
print(f"\n=== 结果:{len(PASS)} passed / {len(FAIL)} failed ===")
if FAIL:
print("FAILED:", FAIL)
sys.exit(1)
def cleanup(cur):
"""按名称前缀清理本脚本造的数据(幂等)"""
cur.execute(f"SELECT id FROM users WHERE name LIKE '{TAG}%'")
uids = [r[0] for r in cur.fetchall()]
if uids:
fmt = ",".join(["%s"] * len(uids))
cur.execute(f"DELETE FROM user_scopes WHERE user_id IN ({fmt})", uids)
cur.execute(f"DELETE FROM user_roles WHERE user_id IN ({fmt})", uids)
cur.execute(f"DELETE FROM users WHERE id IN ({fmt})", uids)
cur.execute(f"SELECT id FROM cabinets WHERE name LIKE '{TAG}%'")
cids = [r[0] for r in cur.fetchall()]
if cids:
fmt = ",".join(["%s"] * len(cids))
# 先清依赖业务数据(订单/记录/日志/仓体/仓板),再删柜子,避免外键报错
cur.execute(f"DELETE FROM charge_orders WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM charge_records WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM device_logs WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM compartments WHERE cabin_board_id IN "
f"(SELECT id FROM cabin_boards WHERE cabinet_id IN ({fmt}))", cids)
cur.execute(f"DELETE FROM cabin_boards WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM cabinets WHERE id IN ({fmt})", cids)
cur.execute(f"SELECT id FROM projects WHERE name LIKE '{TAG}%'")
pids = [r[0] for r in cur.fetchall()]
if pids:
fmt = ",".join(["%s"] * len(pids))
cur.execute(f"DELETE FROM projects WHERE id IN ({fmt})", pids)
cur.execute(f"DELETE FROM organizations WHERE name LIKE '{TAG}%'")
if __name__ == "__main__":
main()

136
AGENTS.md
View File

@ -138,6 +138,38 @@ python tools/db.py exec "UPDATE users SET status=1 WHERE id=1"
python tools/db.py import data.sql
```
**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次)**:
```bash
# 测试库 vs 生产库 全列比对(类型/可空性/默认值/EXTRA/注释 + TIMESTAMP 巡检)
python tools/db_schema_diff.py
# 与代码权威 DDL 比对(migrate.rs 的 LEGACY_COLUMN_TARGETS)
python tools/db_schema_diff.py --vs-ddl
python tools/db_schema_diff.py --db pms --vs-ddl
```
退出码:`0`=无漂移、`1`=发现漂移(可接进部署前置检查)、`2`=参数/连接错误。
> 为什么必须有它:"测试库与生产结构不一致"已经栽了 5 次(BRPOP 卡死、`iccid` NULL、
> `SCHEMA_SQL` 前向引用、测试环境导出 500、生产 `/api/charge-orders` 500),
> 每次都是靠人肉发现。`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**,
> 漂移会长期潜伏。**所有时间列必须是 `DATETIME`**(sqlx 的 `NaiveDateTime` 只与 `DATETIME`
> 兼容,`TIMESTAMP` 只与 `DateTime<Utc>` 兼容,用错就是接口 500)。
端到端验收(会造临时行并自动清理,需该环境的 `JWT_SECRET`):
```bash
python test/schema_alignment_e2e.py --env dev --jwt-secret <JWT_SECRET>
python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-prod
```
> ⚠️ **2026-09-20 生产结构变更(行为变化,非 bug)**:`organizations`、`projects`、`cabinets`、
> `cabin_boards`、`compartments`、`users`、`roles` 这 **7 张表的 `updated_at` 恢复为
> `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`**
> —— 此前它们是"人工从 `TIMESTAMP` 改成 `DATETIME` 时漏掉了 `ON UPDATE`"的偏离状态,
> 所以**这些表的 `updated_at` 从"不会自动刷新"变成"随 `UPDATE` 自动刷新"**,
> 看到 `updated_at` 变动属预期行为。同批变更还把两库全部时间列统一成 `DATETIME`
> (详见 `software/api-server/src/db/migrate.rs` 的 `LEGACY_COLUMN_TARGETS`)。
### 证书
- 阿里云免费SSL证书(acme.sh自动续期)
@ -385,7 +417,14 @@ test_check_permission_user_returns_forbidden
- RBAC 权限码:精确到按钮级别(如 `device:door:unlock`)
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
- 敏感操作(删除/禁用/开门)需二次确认
- TCP 连接认证:设备 login 成功(响应含 `spec` + `suc=1`)前,敏感指令(open/pow_on/pow_off/reset/auth_str)不投递
- **TCP 连接登录门控(上行 + 下行,同一规则)**:同一条 TCP 连接在 `login` 成功前**只允许 `auth_str` / `login`**;
其余任何 `act`(含 `status_post`、`bat_in`、`on`/`off`、`open`/`pow_on`/`pow_off`/`reset`、以及各类应答)
**上行一律丢弃、下行一律不投递**(设备侧收不到任何字节)。
依据:`docs/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。
**判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`,
在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位),
**不是** Redis 在线键 `device:{imei}` —— 该键在验签前就会被写,用它判在线必然误判。
⛔ 不要再按"某个 `act` 算不算敏感指令"来增删名单:规则是"登录前只有 `auth_str`/`login`"(常量 `PRE_LOGIN_ALLOWED_ACTS`)。
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定
@ -755,6 +794,67 @@ tail -f /var/log/nginx/error.log
设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。
### 层级与可见性(**全系统统一口径,所有组织隔离改动一律以此为准**)
```
柜子工厂(我们,总管理员 role_level=2)
└── 客户组织(每个客户一个 organization)
└── 客户在自己组织下新建的操作员 / 组织管理员
```
| 角色 | `users.role`(role_level) | 可见组织范围 |
|------|---------------------------|-------------|
| 总管理员(工厂) | `2` | **全部组织**(跨组织视角,小程序「选择组织」弹层可切换,默认「全部」) |
| 组织管理员(客户) | `1` | **仅自己所属组织**(按 `users.organization_id`) |
| 操作员(客户) | `0` | **仅自己所属组织**(按 `users.organization_id`) |
- **归属链**:客户侧用户的组织归属只认 `users.organization_id`;`organization_id` 为 `NULL` 的客户侧账号**看不到任何组织/设备**(空集,不是报错,也**绝不退化成"全部"**)。
- **两条组织接口的分工(不可混用)**:
- `GET /api/organizations` —— **管理端**(PC)组织管理页,硬校验 `org:view`,返回**全量**组织(操作员无该权限码 → 403)。语义冻结,不得改动。
- `GET /api/my-organizations` —— **小程序**「选择组织」专用,**仅需登录**(不校验 `org:view`),服务端按上表做隔离:工厂全量、客户仅本组织。
- 设备/项目/能耗/日志等所有业务数据的组织隔离,一律按「`role_level >= 2` 放行全量,否则按 `organization_id` 过滤,无组织则空集」实现(例:`routes/h5.rs::visible_org_scope`、`middleware/auth.rs::org_condition`)。
### 数据范围(user_scopes)—— **组织隔离之上的第二道收窄**
> 权威实现:`software/api-server/src/middleware/auth.rs`(`UserScope` / `load_user_scopes` /
> `CurrentUser::effective_scope`)、`routes/scope_guard.rs`(详情/写操作)、`routes/users_perm.rs`(配置校验)。
> 前端入口:**PC 用户管理页 → 操作列「数据范围」**(`web/src/components/UserDataScopeModal.tsx`)。
**范围粒度到「项目级」**(`user_scopes.scope_type`):
| `scope_type` | 含义 | 是否参与鉴权 |
|---|---|---|
| `1` | 组织级 | ✅ 参与。**只允许配成该用户自己所属的组织**,展开为该组织下的全部项目 |
| `2` | 项目级 | ✅ 参与。**实际生效的粒度** |
| `3` | 设备/柜子级 | ❌ **不参与鉴权**(当不存在,读取时直接忽略;写入仍做存在性+归属校验后存档) |
**四种情形的行为(必须严格遵守,改动前先读这一节):**
| 情形 | 行为 |
|---|---|
| **未配置任何 scope 记录** | **不限制**:沿用组织隔离,可见范围与"没做这个功能"时**逐字一致**。⛔ 绝不能解释成"无权限" —— 现存所有客户账号都没配过,那样会一次锁死全部账号 |
| **已配置(≥1 条有效)** | 在组织隔离基础上**再收窄**:`组织隔离 ∧ 项目∈范围`(**取交集,绝不取并集、绝不放大**) |
| **总管理员(`role_level >= 2`)** | **豁免**:始终可见全量,不受 scope 限制(`CurrentUser::effective_scope` 恒返回"不限制",中间件连库都不查) |
| **有记录但一条有效范围都没有**(脏数据 / 只有设备级 / 跨组织 / 项目已删除) | 保守忽略无效行;若因此一条有效范围都不剩 ⇒ 按**未配置 = 不限制**处理,并记 `WARN`(**绝不把用户锁死**) |
**`scope_id` 校验(配置期,`PUT /api/users/{id}/scopes`)**:
- 资源必须**存在**,且**属于该用户所在组织**,否则 `BadRequest`(400);
- 组织级:`scope_id` 必须等于目标用户的 `users.organization_id`(**不允许跨组织配置**);
- 项目级:`projects.organization_id` 必须等于目标用户的 `users.organization_id`;
- 设备级:柜子所属项目的组织必须等于目标用户的 `users.organization_id`(存档但不消费)。
- 全量替换在**事务**里执行:校验全部通过前不删旧数据,避免中途失败留下"范围被清空"的中间态。
- 目标用户 `organization_id` 为 `NULL`(客户侧无组织账号)时,任何带归属的范围都不合法 ⇒ 一律 400。
**鉴权侧如何消费(改动任何列表/详情接口前必须核对):**
- 过滤统一落在**项目**这一层(`cabinets.project_id` / `projects.id`),只有一种收窄形态:
`project_id IN (...)`(柜子维度用 `cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (...))`);
- 列表接口:在**已有的组织条件之后**用 `&&` 追加范围条件(`auth::append_condition`);
- 详情/写操作:走 `routes/scope_guard.rs`(或 h5 的 `verify_cabinet_access`),组织校验通过后再校验资源是否落在范围内,不在则 `Forbidden`;
- 后台任务(Excel 导出)必须把 `scope_project_ids` 一并落库到任务参数里,否则后台任务会绕过范围;
- ⚠️ **配了 scope 的用户被漏叠加的接口 = 数据泄露**。新增任何业务列表/详情接口时,同时叠加组织条件与范围条件。
**操作日志不叠加范围**(经确认,非遗漏):`operation_logs` 按 `user_id → users.organization_id` 归属,与项目/设备没有关联列,项目级范围无法映射。
## 数据模型
### 核心关系
@ -939,6 +1039,40 @@ HSET device:{imei} online 1 rssi 85
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
### 状态位展示约定(**状态位按归属层级展示**)
> 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。
1. **柜级状态位 → 显示在"柜子"层面**
- 数据:`cabinet.status1` / `cabinet.status2`(2 位 hex,**设备上报原样落 Redis**
`device:{imei}.cabinet_status1/2`,接口原样透出为 `status1` / `status2`)。
- 位定义(§3.8):`status1` bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|**bit4 柜门打开**|
bit5 加热风扇|bit6 接触器断开|bit7 喷淋;`status2` bit0 声光报警|**bit1 柜门打开超时**
(阈值硬件定义,**平台只呈现、不自行判定**);`status2` bit2–7 为保留位,**不展示**。
- 页面:PC `web/src/pages/devices/cabinet.tsx`、小程序 `h5-uniapp/src/pages/device-detail/index.vue`。
- **取不到返回 `null`(未知),绝不落 `"00"`** —— `"00"` 是"全部正常"的有效值。
2. **仓体级状态位 → 显示在"仓体"层面**
- 数据:通道 18 字符 hex 的 `batt_status`(byte0) / `pow_status`(byte1);保留位
(`batt_status` bit2–3、`pow_status` bit3–6)**不展示**。
- 页面:PC 柜子详情里的通道卡片、小程序 `pages/compartment-detail/index.vue`。
- **仓体层面不显示任何"门"状态**:协议**没有**"按仓体(舱门)开门"的指令(§4.2 L403)。
3. **柜门是柜级独立动作,不与业务联动,全平台只有一个开门按钮**
- `open` 是**柜级**指令(**不带 `id`**),语义是**「柜门解锁,下发后由人拉开,门不会自动开」**;
没有"关门"指令(关门也是人为动作)。**全平台只有一扇柜门**,不按仓体区分。
- **柜门状态永远来自 `status_post`(约 60 秒一次),不是实时量** —— 界面上必须标注
"来自最近一次上报(约 60 秒一次),非实时"(§4.2 L428)。
- 开门是**独立流程**,与电池插入、充电开始/结束**没有因果关系也没有先后要求**(§5.7、§7.2):
不得出现在"开始充电"流程里、不得改任何订单状态、不得要求存在订单。
- 入口只有一个:PC 详情页按钮(`<Permission code="charge:open_door">` 包裹)+
小程序设备详情页按钮;**仓体(通道)详情页不放开门按钮**。
- 权限码 `charge:open_door`(后端 `check_permission` 硬校验;`device:door:unlock` 是历史误用的
不存在权限码,**禁止再用**)。
4. **解析函数两端必须同源同名**:`web/src/utils/deviceStatus.ts` 与
`h5-uniapp/src/utils/deviceStatus.ts` 各有一组 `parseCabinetStatus1` / `parseCabinetStatus2` /
`cabinetDoorState` / `cabinetStatusTags`(注释互相指认对方文件,改一边务必同步另一边)。
端到端一致性用例见 `test/device_status_parse.test.mjs`(`node test/device_status_parse.test.mjs`,
逐位遍历 0x00–0xFF 断言两端一致)。
### 开发约束(uni-app 特定)
1. **`<text>` 不支持 `display: flex`** — uni-app 的 `<text>` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `<view>`

View File

@ -89,18 +89,8 @@ charging-cabinet/
## TCP 设备通讯协议
> 完整协议文档见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,唯一权威 TCP 文档)
- TCP 长连接,空闲 60 秒/充电 15 秒上报
- 所有设备上报消息平台都会回复 `{"act":"<同请求>","msg_id":N,"suc":1}` 确认
- 响应扁平化,不加 `data` 包裹
- 签名机制:`SHA256(dev_id + auth_str + timestamp)`,取 `hex[56..64]`,**8 位**小写十六进制
- `id` 字段:仓体级 `IMEI-板-通道`,柜级 `IMEI`
- 全局整数单位:voltage mV, current mA, temp K×100, soc ‰
**11 条指令:** login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off / open / pow_on / pow_off / reset
完整请求/响应示例、字段表、签名自测向量、超时与自愈流程 → [docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)
> TCP 设备通讯协议见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。
> 全局整数单位(voltage mV、current mA、temp K×100、soc ‰)见 [docs/API.md](docs/API.md#数据单位规范)。
- 任务需求写到 `tasks/` 目录的md文件
- 通过Agent子代理包裹opencode执行,异步通知

View File

@ -10,7 +10,7 @@
# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错,
# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂)
# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收"
# 的顺序执行,并在收尾打印回滚方法。
# 的顺序执行,并在收尾打印回滚方法;最后按"只保留最近 5 次回滚点"清理历史 `backup-*`。
#
# 用法:
# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收)
@ -34,6 +34,9 @@ $Dst = '/data/pms'
$ApiPort = 9000
$TcpPort = 9001
$Domain = 'app.anzhizhichong.com'
# 回滚点(backup-YYYYMMDD-HHMMSS)保留份数:每次部署新建一份,从不清理会持续涨
# (实测 2026-09-20 已累计 143MB / 4 份,单份最大 99MB)。保留最近 N 次即可回滚。
$KeepBackups = 5
# ---- 路径 -------------------------------------------------------------------
$Repo = Split-Path -Parent $PSScriptRoot
@ -57,7 +60,7 @@ function Send-File([string]$local, [string]$remote) {
# ---- 1. 构建 ---------------------------------------------------------------
if (-not $SkipBuild) {
Step '1/7 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
Step '1/8 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
Say 'api-server ...'
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/api-server/Cargo.toml') }
Say 'device-server ...'
@ -69,7 +72,7 @@ if (-not $SkipBuild) {
}
# ---- 2. 校验产物 + 静态检查 ------------------------------------------------
Step '2/7 校验产物与静态检查'
Step '2/8 校验产物与静态检查'
foreach ($f in @($ApiBin, $DevBin)) {
if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" }
Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB))
@ -80,7 +83,7 @@ if (-not $SkipWeb) {
}
# ---- 3. 服务器备份(可回滚)------------------------------------------------
Step '3/7 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
Step '3/8 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
Invoke-Remote @"
set -e
TS=`$(date +%Y%m%d-%H%M%S)
@ -94,7 +97,7 @@ echo " 备份目录: $Dst/backup-`$TS"
"@
# ---- 4. 上传(临时名,避免 ETXTBSY)----------------------------------------
Step '4/7 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
Step '4/8 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
Send-File $ApiBin "$Dst/api-server/pms-api.new"
Send-File $DevBin "$Dst/device-server/pms-device-server.new"
if (-not $SkipWeb) {
@ -104,7 +107,7 @@ if (-not $SkipWeb) {
}
# ---- 5. 切换 + 重启 --------------------------------------------------------
Step '5/7 原子替换并重启服务'
Step '5/8 原子替换并重启服务'
Invoke-Remote @"
set -e
chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new
@ -119,7 +122,7 @@ nginx -t && systemctl reload nginx
"@
# ---- 6. 验收 ---------------------------------------------------------------
Step '6/7 验收(服务/端口/域名/日志)'
Step '6/8 验收(服务/端口/域名/日志)'
Invoke-Remote @"
echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}'
@ -133,7 +136,7 @@ journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^
"@
# ---- 7. 消费链路探测(无副作用)-------------------------------------------
Step '7/7 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
if (-not $DryRun) {
$probe = @'
import redis, json, time
@ -147,10 +150,72 @@ print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消
python "$env:TEMP\pms_probe.py"
}
# ---- 8. 回滚点保留策略(只保留最近 N 次 backup-YYYYMMDD-HHMMSS)--------------
# 为什么放在最后:本步骤在**验收通过之后**才执行 —— 中途任何一步 throw 都会在这里
# 之前退出,本次及历史的回滚点全部保留(部署失败时最需要回滚点)。
#
# ⚠️ 匹配规则必须**严格**:只认「名字完全等于 backup-8位数字-6位数字」的**目录**。
# /data/pms 下还存在 `backup-20260920-004848-api.env`(文件名带后缀)这类条目,
# 通配 backup-* 会把它一起卷进来 —— 那是**数据/配置备份,不是回滚点,绝不能删**。
# 因此这里用 glob 字符类 + `-d` 判定 + 删除前 `case` 复核,三重限定。
Step "8/8 回滚点保留策略(只保留最近 $KeepBackups 次 backup-YYYYMMDD-HHMMSS)"
Invoke-Remote @"
set -e
shopt -s nullglob
KEEP=$KeepBackups
# glob 字符类只匹配严格的 backup-YYYYMMDD-HHMMSS;nullglob 让无匹配时展开为空数组。
# glob 结果按字典序排列,对该命名格式而言等价于时间戳升序(最早的在前)。
rollbacks=()
for p in $Dst/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "`$p" ]; then
rollbacks+=( "`$p" )
fi
done
total=`${#rollbacks[@]}
echo " 当前回滚点 `$total 个(deploy 会为每次部署新建一份):"
for p in "`${rollbacks[@]}"; do
echo " `$p"
done
if [ "`$total" -gt "`$KEEP" ]; then
drop=`$((total - KEEP))
echo " 将删除最早的 `$drop 个(保留最近 `$KEEP 个):"
i=0
for p in "`${rollbacks[@]}"; do
if [ "`$i" -ge "`$drop" ]; then
break
fi
i=`$((i + 1))
# 复核:名字必须严格符合 backup-YYYYMMDD-HHMMSS,否则拒绝删除(绝不碰其它目录)
case "`$(basename "`$p")" in
backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
echo " 删除 `$p"
rm -rf -- "`$p"
;;
*)
echo " [skip] 名字不符合回滚点模式,拒绝删除: `$p"
;;
esac
done
else
echo " 回滚点数量未超过 `$KEEP,无需清理"
fi
kept=()
for p in $Dst/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "`$p" ]; then
kept+=( "`$p" )
fi
done
echo " 清理后保留的回滚点 `${#kept[@]} 个:"
for p in "`${kept[@]}"; do
echo " `$p"
done
echo " $Dst 占用: `$(du -sh $Dst | cut -f1)"
"@
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
Say "正式环境:https://$Domain/pms/"
Say "API :$ApiPort TCP :$TcpPort"
Say ''
Say '回滚方法(如需):'
Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`""
Say ' 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份)'
Say " 备份目录见 $Dst/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 $KeepBackups 次)"

View File

@ -13,6 +13,16 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# ===== 安全响应头(2026-09 安全加固)=====
# HSTS:本域强制 HTTPS(暂不加 includeSubDomains,避免影响未知子域)
add_header Strict-Transport-Security "max-age=31536000" always;
# 禁止 MIME 嗅探:/uploads 免认证同源直出,防上传内容被当 HTML/JS 执行
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CSP 先 Report-Only 观察(只上报不拦截),确认 SPA 无违规后再考虑收紧为强制
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
# 产品介绍页
root /data/landing;
index index.html;
@ -26,6 +36,26 @@ server {
client_max_body_size 500m;
}
# 登录接口限流(防暴力破解第二道;第一道在 api-server 内按手机号+IP 计数锁定)
location = /api/pms/auth/login {
limit_req zone=auth_login_zone burst=10 nodelay;
proxy_pass http://127.0.0.1:9000/api/auth/login;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 验证码接口限流(免认证且逐像素渲染,是 CPU 放大攻击面)
location = /api/pms/auth/challenge {
limit_req zone=auth_challenge_zone burst=10 nodelay;
proxy_pass http://127.0.0.1:9000/api/auth/challenge;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/pms/ {
proxy_pass http://127.0.0.1:9000/api/;
proxy_set_header Host $host;
@ -35,8 +65,9 @@ server {
client_max_body_size 10m;
}
# 头像上传文件
# 头像上传文件(免认证同源直出)——限流;nosniff 由 server 级继承
location /uploads/ {
limit_req zone=uploads_zone burst=60 nodelay;
proxy_pass http://127.0.0.1:9000/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -8,23 +8,70 @@ server {
listen 443 ssl http2;
server_name test.anzhizhichong.com;
ssl_certificate /etc/nginx/ssl/test.anzhizhichong.com.pem;
ssl_certificate_key /etc/nginx/ssl/test.anzhizhichong.com.key;
ssl_certificate /etc/nginx/ssl/app.anzhizhichong.com.pem;
ssl_certificate_key /etc/nginx/ssl/app.anzhizhichong.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# ===== 安全响应头(2026-09 安全加固)=====
add_header Strict-Transport-Security "max-age=31536000" always;
# 禁止 MIME 嗅探:/uploads 免认证同源直出,防上传内容被当 HTML/JS 执行
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CSP 先 Report-Only 观察(只上报不拦截)
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
root /data/pms_dev;
index index.html;
location /pms/api/ {
proxy_pass http://127.0.0.1:3000/;
# 登录接口限流(防暴力破解第二道;第一道在 api-server 内按手机号+IP 计数锁定)
location = /api/pms/auth/login {
limit_req zone=auth_login_zone burst=10 nodelay;
proxy_pass http://127.0.0.1:3000/api/auth/login;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 验证码接口限流(免认证且逐像素渲染,是 CPU 放大攻击面)
location = /api/pms/auth/challenge {
limit_req zone=auth_challenge_zone burst=10 nodelay;
proxy_pass http://127.0.0.1:3000/api/auth/challenge;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/pms/ {
proxy_pass http://127.0.0.1:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 10m;
}
# 上传文件(与生产一致直出;此前测试站缺此 location,/uploads/ 会落到 root 变 404)
location /uploads/ {
limit_req zone=uploads_zone burst=60 nodelay;
proxy_pass http://127.0.0.1:3000/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 30d;
}
location /pms/ {
alias /data/pms_dev/web/;
try_files $uri $uri/ /pms/index.html;
}
location /h5/ {
alias /data/pms_dev/h5/;
try_files $uri $uri/ /h5/index.html;
}
}

View File

@ -4,7 +4,10 @@ After=network.target
[Service]
Type=simple
User=root
# 降权运行(2026-09 安全加固):仅需读 .env(systemd 以 root 读取 EnvironmentFile)、
# 可写 ./uploads 与 ./exports(两者已 chown pms:pms),9000 端口 >1024 无需特权
User=pms
Group=pms
WorkingDirectory=/data/pms/api-server
ExecStart=/data/pms/api-server/pms-api
EnvironmentFile=/data/pms/api-server/.env

View File

@ -4,7 +4,10 @@ After=network.target
[Service]
Type=simple
User=root
# 降权运行(2026-09 安全加固):仅需读 .env(systemd 以 root 读取 EnvironmentFile)、
# 可写 ./uploads 与 ./exports(两者已 chown pms:pms),3000 端口 >1024 无需特权
User=pms
Group=pms
WorkingDirectory=/data/pms_dev/api-server
ExecStart=/data/pms_dev/api-server/pms-api
EnvironmentFile=/data/pms_dev/api-server/.env

View File

@ -4,7 +4,9 @@ After=network.target
[Service]
Type=simple
User=root
# 降权运行(2026-09 安全加固):9001 端口 >1024 无需特权,device-server 不写业务文件
User=pms
Group=pms
WorkingDirectory=/data/pms/device-server
ExecStart=/data/pms/device-server/pms-device-server
EnvironmentFile=/data/pms/device-server/.env

View File

@ -4,7 +4,9 @@ After=network.target
[Service]
Type=simple
User=root
# 降权运行(2026-09 安全加固):3001 端口 >1024 无需特权,device-server 不写业务文件
User=pms
Group=pms
WorkingDirectory=/data/pms_dev/device-server
ExecStart=/data/pms_dev/device-server/device-server
EnvironmentFile=/data/pms_dev/device-server/.env

48
deploy_output.txt Normal file
View File

@ -0,0 +1,48 @@
pms-api.new: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.0.0, stripped
6f53f17eb9e40579e8247e33739ffc1c41114382c3c208885a89a160ed66e640 pms-api.new
=== is-active ===
active
=== port ===
LISTEN 0 128 0.0.0.0:3000 0.0.0.0:* users:(("pms-api",pid=735613,fd=12))
=== ???????? ===
Sep 20 01:43:04 iZbp10r34ovrtuih2vq1hbZ pms-api[734262]: 2026-09-19T17:43:04.829257Z ERROR pms_api::error: 数据库错误: error occurred while decoding column "abstract_id": unexpected null; try decoding as an `Option`
Sep 20 01:43:05 iZbp10r34ovrtuih2vq1hbZ pms-api[734262]: 2026-09-19T17:43:05.073410Z INFO pms_api::routes::energy_stats: 能耗统计导出完成 task_id=1 file=./exports/能耗统计_20260919-20260920.xlsx
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ systemd[1]: Stopping PMS API Server - Test Environment...
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ systemd[1]: pms-api-test.service: Deactivated successfully.
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ systemd[1]: Stopped PMS API Server - Test Environment.
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ systemd[1]: pms-api-test.service: Consumed 2.914s CPU time, 2.0M memory peak.
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ systemd[1]: Started PMS API Server - Test Environment.
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.495964Z INFO pms_api: 配置加载完成
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.506602Z INFO pms_api::db: MySQL 连接池已建立
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.506636Z INFO pms_api: 数据库连接成功
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.744502Z INFO pms_api::db::migrate: 数据库索引创建完成
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.865018Z INFO pms_api::db::migrate: 数据库列级迁移完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.030363Z INFO pms_api::db::migrate: 默认权限码初始化完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.562796Z INFO pms_api::db::migrate: 默认角色及权限分配完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.563267Z INFO pms_api::db::migrate: 数据库迁移完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.563290Z INFO pms_api: 数据库迁移完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.567880Z INFO pms_api::redis: Redis 连接已建立
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.567894Z INFO pms_api: Redis 连接成功
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568035Z INFO pms_api::workers::report_worker: [report_worker] 启动
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568157Z INFO pms_api::workers::reply_worker: [reply_worker] 启动
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568218Z INFO pms_api::workers::order_timeout: [order_timeout] 启动订单超时兜底,扫描间隔=300s,超时阈值=10h
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568287Z INFO pms_api::workers::log_retention: [log_retention] 启动设备日志清理,保留 90 天,执行间隔 21600s,单批 5000 行
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.569024Z INFO pms_api: HTTP 服务器启动于 http://0.0.0.0:3000
=== ???? ===
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.506602Z INFO pms_api::db: MySQL 连接池已建立
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.506636Z INFO pms_api: 数据库连接成功
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.744502Z INFO pms_api::db::migrate: 数据库索引创建完成
Sep 20 01:43:43 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:43.865018Z INFO pms_api::db::migrate: 数据库列级迁移完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.030363Z INFO pms_api::db::migrate: 默认权限码初始化完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.562796Z INFO pms_api::db::migrate: 默认角色及权限分配完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.563267Z INFO pms_api::db::migrate: 数据库迁移完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.563290Z INFO pms_api: 数据库迁移完成
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.567880Z INFO pms_api::redis: Redis 连接已建立
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.567894Z INFO pms_api: Redis 连接成功
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568035Z INFO pms_api::workers::report_worker: [report_worker] 启动
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568157Z INFO pms_api::workers::reply_worker: [reply_worker] 启动
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568218Z INFO pms_api::workers::order_timeout: [order_timeout] 启动订单超时兜底,扫描间隔=300s,超时阈值=10h
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.568287Z INFO pms_api::workers::log_retention: [log_retention] 启动设备日志清理,保留 90 天,执行间隔 21600s,单批 5000 行
Sep 20 01:43:44 iZbp10r34ovrtuih2vq1hbZ pms-api[735613]: 2026-09-19T17:43:44.569024Z INFO pms_api: HTTP 服务器启动于 http://0.0.0.0:3000
=== ?? ===
ls: cannot access '/data/pms_dev/api-server/'$'\r': No such file or directory

15
deploy_remote.sh Normal file
View File

@ -0,0 +1,15 @@
set -e
cd /data/pms_dev/api-server
file pms-api.new
sha256sum pms-api.new
chmod +x pms-api.new
cp -a pms-api pms-api.bak-20260920-0143
systemctl stop pms-api-test
mv -f pms-api.new pms-api
systemctl start pms-api-test
sleep 3
echo '=== is-active ==='; systemctl is-active pms-api-test
echo '=== port ==='; ss -lntp | grep ':3000' || true
echo '=== ???????? ==='; journalctl -u pms-api-test --since '-2min' --no-pager | grep -Ei 'index|??|migrate|??' || true
echo '=== ???? ==='; journalctl -u pms-api-test -n 15 --no-pager
echo '=== ?? ==='; ls -l /data/pms_dev/api-server/

Some files were not shown because too many files have changed in this diff Show More