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:
parent
db1264953a
commit
ee92102f3c
28
.tmp-energy-verify/baseline_old_binary.txt
Normal file
28
.tmp-energy-verify/baseline_old_binary.txt
Normal 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]
|
||||||
31
.tmp-energy-verify/check_indexes.py
Normal file
31
.tmp-energy-verify/check_indexes.py
Normal 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()
|
||||||
39
.tmp-energy-verify/check_timezone.py
Normal file
39
.tmp-energy-verify/check_timezone.py
Normal 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()
|
||||||
22
.tmp-energy-verify/cleanup_output.txt
Normal file
22
.tmp-energy-verify/cleanup_output.txt
Normal 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'))
|
||||||
|
|
||||||
|
临时数据清理完成
|
||||||
72
.tmp-energy-verify/cleanup_temp_data.py
Normal file
72
.tmp-energy-verify/cleanup_temp_data.py
Normal 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临时数据清理完成")
|
||||||
8
.tmp-energy-verify/export_xlsx_content.txt
Normal file
8
.tmp-energy-verify/export_xlsx_content.txt
Normal 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)
|
||||||
BIN
.tmp-energy-verify/exports/能耗统计_20260919-20260920.xlsx
Normal file
BIN
.tmp-energy-verify/exports/能耗统计_20260919-20260920.xlsx
Normal file
Binary file not shown.
2
.tmp-energy-verify/facts.txt
Normal file
2
.tmp-energy-verify/facts.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
SUPER=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjYzMTUsImlhdCI6MTc4OTgzOTkxNX0.36ocLLksl-sZ_PK8M1mI8M9i9NOq0OcYv_xOdz2iCQA
|
||||||
|
ORG1=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjYzMTYsImlhdCI6MTc4OTgzOTkxNn0.OpOgTeYh9UdjReUVLh698TN9OOjmuPMxbao8LBZHQDM
|
||||||
8
.tmp-energy-verify/index_check.txt
Normal file
8
.tmp-energy-verify/index_check.txt
Normal 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 列)
|
||||||
28
.tmp-energy-verify/isolation_org1.txt
Normal file
28
.tmp-energy-verify/isolation_org1.txt
Normal 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]
|
||||||
28
.tmp-energy-verify/isolation_super.txt
Normal file
28
.tmp-energy-verify/isolation_super.txt
Normal 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]
|
||||||
32
.tmp-energy-verify/make_role_tokens.py
Normal file
32
.tmp-energy-verify/make_role_tokens.py
Normal 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())
|
||||||
58
.tmp-energy-verify/setup_orgB.py
Normal file
58
.tmp-energy-verify/setup_orgB.py
Normal 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()
|
||||||
99
.tmp-energy-verify/setup_temp_data.py
Normal file
99
.tmp-energy-verify/setup_temp_data.py
Normal 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()
|
||||||
158
.tmp-energy-verify/sql_verify_output.txt
Normal file
158
.tmp-energy-verify/sql_verify_output.txt
Normal 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 行
|
||||||
24
.tmp-energy-verify/temp_ids.json
Normal file
24
.tmp-energy-verify/temp_ids.json
Normal 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
|
||||||
|
]
|
||||||
|
}
|
||||||
8
.tmp-energy-verify/temp_ids_orgB.json
Normal file
8
.tmp-energy-verify/temp_ids_orgB.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"org_id": 4,
|
||||||
|
"project_id": 3,
|
||||||
|
"cabinet_id": 4,
|
||||||
|
"board_id": 5,
|
||||||
|
"compartment_id": 7,
|
||||||
|
"record_id": 16
|
||||||
|
}
|
||||||
6
.tmp-energy-verify/timezone_check.txt
Normal file
6
.tmp-energy-verify/timezone_check.txt
Normal 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)))
|
||||||
1
.tmp-energy-verify/token.txt
Normal file
1
.tmp-energy-verify/token.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5NDYsImlhdCI6MTc4OTgzOTU0Nn0.X-T6uBTENj9UK9seDIyyJJXopJcNJfSfDZ1C60AP-yA
|
||||||
1
.tmp-energy-verify/token_local.txt
Normal file
1
.tmp-energy-verify/token_local.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5ODksImlhdCI6MTc4OTgzOTU4OX0.ZiKHmR2fKvfSLaINjDq_R9npcFldGqP4WOQessAotxg
|
||||||
1
.tmp-energy-verify/token_testenv.txt
Normal file
1
.tmp-energy-verify/token_testenv.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5ODksImlhdCI6MTc4OTgzOTU4OX0.Y0Tju_TsC07S-qyIhD4cRpT8I1uqzTuCGWVH6QOorJM
|
||||||
13
.tmp-energy-verify/verify_endpoints.sh
Normal file
13
.tmp-energy-verify/verify_endpoints.sh
Normal 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"
|
||||||
69
.tmp-energy-verify/verify_live_testenv.py
Normal file
69
.tmp-energy-verify/verify_live_testenv.py
Normal 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()
|
||||||
16
.tmp-energy-verify/verify_live_testenv_output.txt
Normal file
16
.tmp-energy-verify/verify_live_testenv_output.txt
Normal 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
|
||||||
28
.tmp-energy-verify/verify_new_binary.txt
Normal file
28
.tmp-energy-verify/verify_new_binary.txt
Normal 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]
|
||||||
160
.tmp-energy-verify/verify_sql.py
Normal file
160
.tmp-energy-verify/verify_sql.py
Normal 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()
|
||||||
BIN
.tmp-schema-align/__pycache__/inspect.cpython-314.pyc
Normal file
BIN
.tmp-schema-align/__pycache__/inspect.cpython-314.pyc
Normal file
Binary file not shown.
2
.tmp-schema-align/cargo-check.txt
Normal file
2
.tmp-schema-align/cargo-check.txt
Normal 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
|
||||||
1
.tmp-schema-align/cargo-check2.txt
Normal file
1
.tmp-schema-align/cargo-check2.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.80s
|
||||||
151
.tmp-schema-align/cargo-test.txt
Normal file
151
.tmp-schema-align/cargo-test.txt
Normal 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
|
||||||
|
|
||||||
152
.tmp-schema-align/cargo-test2.txt
Normal file
152
.tmp-schema-align/cargo-test2.txt
Normal 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
|
||||||
|
|
||||||
76
.tmp-schema-align/compare.py
Normal file
76
.tmp-schema-align/compare.py
Normal 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))
|
||||||
58
.tmp-schema-align/compare_full.py
Normal file
58
.tmp-schema-align/compare_full.py
Normal 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]))
|
||||||
42
.tmp-schema-align/compare_time.py
Normal file
42
.tmp-schema-align/compare_time.py
Normal 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])
|
||||||
12
.tmp-schema-align/dev_sql.py
Normal file
12
.tmp-schema-align/dev_sql.py
Normal 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)
|
||||||
41
.tmp-schema-align/diff_detail.py
Normal file
41
.tmp-schema-align/diff_detail.py
Normal 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]))
|
||||||
106
.tmp-schema-align/dryrun.out.txt
Normal file
106
.tmp-schema-align/dryrun.out.txt
Normal 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 次)
|
||||||
152
.tmp-schema-align/endpoint_verify.py
Normal file
152
.tmp-schema-align/endpoint_verify.py
Normal 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()
|
||||||
51
.tmp-schema-align/export_probe.py
Normal file
51
.tmp-schema-align/export_probe.py
Normal 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))
|
||||||
67
.tmp-schema-align/export_verify.py
Normal file
67
.tmp-schema-align/export_verify.py
Normal 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)
|
||||||
8
.tmp-schema-align/extract_step8.ps1
Normal file
8
.tmp-schema-align/extract_step8.ps1
Normal 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)"
|
||||||
17
.tmp-schema-align/leftover_check.py
Normal file
17
.tmp-schema-align/leftover_check.py
Normal 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()
|
||||||
61
.tmp-schema-align/probe1.py
Normal file
61
.tmp-schema-align/probe1.py
Normal 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()
|
||||||
37
.tmp-schema-align/probe2.py
Normal file
37
.tmp-schema-align/probe2.py
Normal 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])
|
||||||
18
.tmp-schema-align/probe3.py
Normal file
18
.tmp-schema-align/probe3.py
Normal 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()
|
||||||
42
.tmp-schema-align/prod_probe.py
Normal file
42
.tmp-schema-align/prod_probe.py
Normal 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))
|
||||||
6
.tmp-schema-align/remote-backup.sh
Normal file
6
.tmp-schema-align/remote-backup.sh
Normal 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
|
||||||
5
.tmp-schema-align/remote-backup2.sh
Normal file
5
.tmp-schema-align/remote-backup2.sh
Normal 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"
|
||||||
1
.tmp-schema-align/remote-backup3.sh
Normal file
1
.tmp-schema-align/remote-backup3.sh
Normal file
@ -0,0 +1 @@
|
|||||||
|
/usr/local/bin/pms-backup-db.sh
|
||||||
20
.tmp-schema-align/remote-retention-test.sh
Normal file
20
.tmp-schema-align/remote-retention-test.sh
Normal 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
|
||||||
3
.tmp-schema-align/remote1.sh
Normal file
3
.tmp-schema-align/remote1.sh
Normal 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"
|
||||||
18
.tmp-schema-align/remote10.sh
Normal file
18
.tmp-schema-align/remote10.sh
Normal 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' '
|
||||||
5
.tmp-schema-align/remote2.sh
Normal file
5
.tmp-schema-align/remote2.sh
Normal 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"
|
||||||
5
.tmp-schema-align/remote3.sh
Normal file
5
.tmp-schema-align/remote3.sh
Normal 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
|
||||||
1
.tmp-schema-align/remote4.sh
Normal file
1
.tmp-schema-align/remote4.sh
Normal file
@ -0,0 +1 @@
|
|||||||
|
journalctl -u pms-api-test --since '-2 min' --no-pager | grep -iE 'ERROR' | tail -6
|
||||||
6
.tmp-schema-align/remote5.sh
Normal file
6
.tmp-schema-align/remote5.sh
Normal 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
|
||||||
29
.tmp-schema-align/remote6.sh
Normal file
29
.tmp-schema-align/remote6.sh
Normal 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
|
||||||
57
.tmp-schema-align/remote7.sh
Normal file
57
.tmp-schema-align/remote7.sh
Normal 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
|
||||||
12
.tmp-schema-align/remote8.sh
Normal file
12
.tmp-schema-align/remote8.sh
Normal 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 已无本次临时文件"
|
||||||
27
.tmp-schema-align/remote9.sh
Normal file
27
.tmp-schema-align/remote9.sh
Normal 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"
|
||||||
50
.tmp-schema-align/retention.sh
Normal file
50
.tmp-schema-align/retention.sh
Normal 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)"
|
||||||
0
.tmp-schema-align/run-dev/run1.err.txt
Normal file
0
.tmp-schema-align/run-dev/run1.err.txt
Normal file
17
.tmp-schema-align/run-dev/run1.out.txt
Normal file
17
.tmp-schema-align/run-dev/run1.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-dev/run2.err.txt
Normal file
0
.tmp-schema-align/run-dev/run2.err.txt
Normal file
11
.tmp-schema-align/run-dev/run2.out.txt
Normal file
11
.tmp-schema-align/run-dev/run2.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-dev/run3.err.txt
Normal file
0
.tmp-schema-align/run-dev/run3.err.txt
Normal file
11
.tmp-schema-align/run-dev/run3.out.txt
Normal file
11
.tmp-schema-align/run-dev/run3.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-dev/run4.err.txt
Normal file
0
.tmp-schema-align/run-dev/run4.err.txt
Normal file
10
.tmp-schema-align/run-dev/run4.out.txt
Normal file
10
.tmp-schema-align/run-dev/run4.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-dev/run5.err.txt
Normal file
0
.tmp-schema-align/run-dev/run5.err.txt
Normal file
42
.tmp-schema-align/run-dev/run5.out.txt
Normal file
42
.tmp-schema-align/run-dev/run5.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-dev/run6.err.txt
Normal file
0
.tmp-schema-align/run-dev/run6.err.txt
Normal file
10
.tmp-schema-align/run-dev/run6.out.txt
Normal file
10
.tmp-schema-align/run-dev/run6.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-dev/run7.err.txt
Normal file
0
.tmp-schema-align/run-dev/run7.err.txt
Normal file
10
.tmp-schema-align/run-dev/run7.out.txt
Normal file
10
.tmp-schema-align/run-dev/run7.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-prod/prod-run1.err.txt
Normal file
0
.tmp-schema-align/run-prod/prod-run1.err.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run1.out.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run1.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-prod/prod-run2.err.txt
Normal file
0
.tmp-schema-align/run-prod/prod-run2.err.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run2.out.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run2.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-prod/prod-run3.err.txt
Normal file
0
.tmp-schema-align/run-prod/prod-run3.err.txt
Normal file
28
.tmp-schema-align/run-prod/prod-run3.out.txt
Normal file
28
.tmp-schema-align/run-prod/prod-run3.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-prod/prod-run4.err.txt
Normal file
0
.tmp-schema-align/run-prod/prod-run4.err.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run4.out.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run4.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-prod/prod-run5.err.txt
Normal file
0
.tmp-schema-align/run-prod/prod-run5.err.txt
Normal file
13
.tmp-schema-align/run-prod/prod-run5.out.txt
Normal file
13
.tmp-schema-align/run-prod/prod-run5.out.txt
Normal 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: 数据库迁移完成
|
||||||
0
.tmp-schema-align/run-prod/prod-run6.err.txt
Normal file
0
.tmp-schema-align/run-prod/prod-run6.err.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run6.out.txt
Normal file
10
.tmp-schema-align/run-prod/prod-run6.out.txt
Normal 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: 数据库迁移完成
|
||||||
5
.tmp-scope-check/ddl_before.json
Normal file
5
.tmp-scope-check/ddl_before.json
Normal 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"
|
||||||
|
}
|
||||||
402
.tmp-scope-check/scope_isolation_check.py
Normal file
402
.tmp-scope-check/scope_isolation_check.py
Normal 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
136
AGENTS.md
@ -138,6 +138,38 @@ python tools/db.py exec "UPDATE users SET status=1 WHERE id=1"
|
|||||||
python tools/db.py import data.sql
|
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自动续期)
|
- 阿里云免费SSL证书(acme.sh自动续期)
|
||||||
@ -385,7 +417,14 @@ test_check_permission_user_returns_forbidden
|
|||||||
- RBAC 权限码:精确到按钮级别(如 `device:door:unlock`)
|
- RBAC 权限码:精确到按钮级别(如 `device:door:unlock`)
|
||||||
- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免
|
- 组织数据隔离:所有写操作校验资源归属(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"`)
|
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
|
||||||
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
|
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
|
||||||
- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定
|
- **`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 居中)
|
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
|
||||||
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
|
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 特定)
|
### 开发约束(uni-app 特定)
|
||||||
|
|
||||||
1. **`<text>` 不支持 `display: flex`** — uni-app 的 `<text>` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `<view>`
|
1. **`<text>` 不支持 `display: flex`** — uni-app 的 `<text>` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `<view>`
|
||||||
|
|||||||
14
README.md
14
README.md
@ -89,18 +89,8 @@ charging-cabinet/
|
|||||||
|
|
||||||
## TCP 设备通讯协议
|
## TCP 设备通讯协议
|
||||||
|
|
||||||
> 完整协议文档见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,唯一权威 TCP 文档)
|
> TCP 设备通讯协议见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。
|
||||||
|
> 全局整数单位(voltage mV、current mA、temp K×100、soc ‰)见 [docs/API.md](docs/API.md#数据单位规范)。
|
||||||
- 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)
|
|
||||||
|
|
||||||
- 任务需求写到 `tasks/` 目录的md文件
|
- 任务需求写到 `tasks/` 目录的md文件
|
||||||
- 通过Agent子代理包裹opencode执行,异步通知
|
- 通过Agent子代理包裹opencode执行,异步通知
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错,
|
# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错,
|
||||||
# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂)
|
# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂)
|
||||||
# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收"
|
# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收"
|
||||||
# 的顺序执行,并在收尾打印回滚方法。
|
# 的顺序执行,并在收尾打印回滚方法;最后按"只保留最近 5 次回滚点"清理历史 `backup-*`。
|
||||||
#
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收)
|
# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收)
|
||||||
@ -34,6 +34,9 @@ $Dst = '/data/pms'
|
|||||||
$ApiPort = 9000
|
$ApiPort = 9000
|
||||||
$TcpPort = 9001
|
$TcpPort = 9001
|
||||||
$Domain = 'app.anzhizhichong.com'
|
$Domain = 'app.anzhizhichong.com'
|
||||||
|
# 回滚点(backup-YYYYMMDD-HHMMSS)保留份数:每次部署新建一份,从不清理会持续涨
|
||||||
|
# (实测 2026-09-20 已累计 143MB / 4 份,单份最大 99MB)。保留最近 N 次即可回滚。
|
||||||
|
$KeepBackups = 5
|
||||||
|
|
||||||
# ---- 路径 -------------------------------------------------------------------
|
# ---- 路径 -------------------------------------------------------------------
|
||||||
$Repo = Split-Path -Parent $PSScriptRoot
|
$Repo = Split-Path -Parent $PSScriptRoot
|
||||||
@ -57,7 +60,7 @@ function Send-File([string]$local, [string]$remote) {
|
|||||||
|
|
||||||
# ---- 1. 构建 ---------------------------------------------------------------
|
# ---- 1. 构建 ---------------------------------------------------------------
|
||||||
if (-not $SkipBuild) {
|
if (-not $SkipBuild) {
|
||||||
Step '1/7 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
|
Step '1/8 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
|
||||||
Say 'api-server ...'
|
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') }
|
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 ...'
|
Say 'device-server ...'
|
||||||
@ -69,7 +72,7 @@ if (-not $SkipBuild) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- 2. 校验产物 + 静态检查 ------------------------------------------------
|
# ---- 2. 校验产物 + 静态检查 ------------------------------------------------
|
||||||
Step '2/7 校验产物与静态检查'
|
Step '2/8 校验产物与静态检查'
|
||||||
foreach ($f in @($ApiBin, $DevBin)) {
|
foreach ($f in @($ApiBin, $DevBin)) {
|
||||||
if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" }
|
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))
|
Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB))
|
||||||
@ -80,7 +83,7 @@ if (-not $SkipWeb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- 3. 服务器备份(可回滚)------------------------------------------------
|
# ---- 3. 服务器备份(可回滚)------------------------------------------------
|
||||||
Step '3/7 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
|
Step '3/8 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
|
||||||
Invoke-Remote @"
|
Invoke-Remote @"
|
||||||
set -e
|
set -e
|
||||||
TS=`$(date +%Y%m%d-%H%M%S)
|
TS=`$(date +%Y%m%d-%H%M%S)
|
||||||
@ -94,7 +97,7 @@ echo " 备份目录: $Dst/backup-`$TS"
|
|||||||
"@
|
"@
|
||||||
|
|
||||||
# ---- 4. 上传(临时名,避免 ETXTBSY)----------------------------------------
|
# ---- 4. 上传(临时名,避免 ETXTBSY)----------------------------------------
|
||||||
Step '4/7 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
|
Step '4/8 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
|
||||||
Send-File $ApiBin "$Dst/api-server/pms-api.new"
|
Send-File $ApiBin "$Dst/api-server/pms-api.new"
|
||||||
Send-File $DevBin "$Dst/device-server/pms-device-server.new"
|
Send-File $DevBin "$Dst/device-server/pms-device-server.new"
|
||||||
if (-not $SkipWeb) {
|
if (-not $SkipWeb) {
|
||||||
@ -104,7 +107,7 @@ if (-not $SkipWeb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- 5. 切换 + 重启 --------------------------------------------------------
|
# ---- 5. 切换 + 重启 --------------------------------------------------------
|
||||||
Step '5/7 原子替换并重启服务'
|
Step '5/8 原子替换并重启服务'
|
||||||
Invoke-Remote @"
|
Invoke-Remote @"
|
||||||
set -e
|
set -e
|
||||||
chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new
|
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. 验收 ---------------------------------------------------------------
|
# ---- 6. 验收 ---------------------------------------------------------------
|
||||||
Step '6/7 验收(服务/端口/域名/日志)'
|
Step '6/8 验收(服务/端口/域名/日志)'
|
||||||
Invoke-Remote @"
|
Invoke-Remote @"
|
||||||
echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
|
echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
|
||||||
echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}'
|
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. 消费链路探测(无副作用)-------------------------------------------
|
# ---- 7. 消费链路探测(无副作用)-------------------------------------------
|
||||||
Step '7/7 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
|
Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
$probe = @'
|
$probe = @'
|
||||||
import redis, json, time
|
import redis, json, time
|
||||||
@ -147,10 +150,72 @@ print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消
|
|||||||
python "$env:TEMP\pms_probe.py"
|
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
|
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
|
||||||
Say "正式环境:https://$Domain/pms/"
|
Say "正式环境:https://$Domain/pms/"
|
||||||
Say "API :$ApiPort TCP :$TcpPort"
|
Say "API :$ApiPort TCP :$TcpPort"
|
||||||
Say ''
|
Say ''
|
||||||
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 " 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 次)"
|
||||||
|
|||||||
@ -13,6 +13,16 @@ server {
|
|||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
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;
|
root /data/landing;
|
||||||
index index.html;
|
index index.html;
|
||||||
@ -26,6 +36,26 @@ server {
|
|||||||
client_max_body_size 500m;
|
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/ {
|
location /api/pms/ {
|
||||||
proxy_pass http://127.0.0.1:9000/api/;
|
proxy_pass http://127.0.0.1:9000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@ -35,8 +65,9 @@ server {
|
|||||||
client_max_body_size 10m;
|
client_max_body_size 10m;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 头像上传文件
|
# 头像上传文件(免认证同源直出)——限流;nosniff 由 server 级继承
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
|
limit_req zone=uploads_zone burst=60 nodelay;
|
||||||
proxy_pass http://127.0.0.1:9000/uploads/;
|
proxy_pass http://127.0.0.1:9000/uploads/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@ -8,23 +8,70 @@ server {
|
|||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name test.anzhizhichong.com;
|
server_name test.anzhizhichong.com;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/test.anzhizhichong.com.pem;
|
ssl_certificate /etc/nginx/ssl/app.anzhizhichong.com.pem;
|
||||||
ssl_certificate_key /etc/nginx/ssl/test.anzhizhichong.com.key;
|
ssl_certificate_key /etc/nginx/ssl/app.anzhizhichong.com.key;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
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;
|
root /data/pms_dev;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /pms/api/ {
|
# 登录接口限流(防暴力破解第二道;第一道在 api-server 内按手机号+IP 计数锁定)
|
||||||
proxy_pass http://127.0.0.1:3000/;
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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/ {
|
location /pms/ {
|
||||||
|
alias /data/pms_dev/web/;
|
||||||
try_files $uri $uri/ /pms/index.html;
|
try_files $uri $uri/ /pms/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /h5/ {
|
||||||
|
alias /data/pms_dev/h5/;
|
||||||
|
try_files $uri $uri/ /h5/index.html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,10 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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
|
WorkingDirectory=/data/pms/api-server
|
||||||
ExecStart=/data/pms/api-server/pms-api
|
ExecStart=/data/pms/api-server/pms-api
|
||||||
EnvironmentFile=/data/pms/api-server/.env
|
EnvironmentFile=/data/pms/api-server/.env
|
||||||
|
|||||||
@ -4,7 +4,10 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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
|
WorkingDirectory=/data/pms_dev/api-server
|
||||||
ExecStart=/data/pms_dev/api-server/pms-api
|
ExecStart=/data/pms_dev/api-server/pms-api
|
||||||
EnvironmentFile=/data/pms_dev/api-server/.env
|
EnvironmentFile=/data/pms_dev/api-server/.env
|
||||||
|
|||||||
@ -4,7 +4,9 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
# 降权运行(2026-09 安全加固):9001 端口 >1024 无需特权,device-server 不写业务文件
|
||||||
|
User=pms
|
||||||
|
Group=pms
|
||||||
WorkingDirectory=/data/pms/device-server
|
WorkingDirectory=/data/pms/device-server
|
||||||
ExecStart=/data/pms/device-server/pms-device-server
|
ExecStart=/data/pms/device-server/pms-device-server
|
||||||
EnvironmentFile=/data/pms/device-server/.env
|
EnvironmentFile=/data/pms/device-server/.env
|
||||||
|
|||||||
@ -4,7 +4,9 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
# 降权运行(2026-09 安全加固):3001 端口 >1024 无需特权,device-server 不写业务文件
|
||||||
|
User=pms
|
||||||
|
Group=pms
|
||||||
WorkingDirectory=/data/pms_dev/device-server
|
WorkingDirectory=/data/pms_dev/device-server
|
||||||
ExecStart=/data/pms_dev/device-server/device-server
|
ExecStart=/data/pms_dev/device-server/device-server
|
||||||
EnvironmentFile=/data/pms_dev/device-server/.env
|
EnvironmentFile=/data/pms_dev/device-server/.env
|
||||||
|
|||||||
48
deploy_output.txt
Normal file
48
deploy_output.txt
Normal 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
15
deploy_remote.sh
Normal 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
Loading…
x
Reference in New Issue
Block a user