diff --git a/.tmp-energy-verify/baseline_old_binary.txt b/.tmp-energy-verify/baseline_old_binary.txt new file mode 100644 index 0000000..efb3264 --- /dev/null +++ b/.tmp-energy-verify/baseline_old_binary.txt @@ -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] diff --git a/.tmp-energy-verify/check_indexes.py b/.tmp-energy-verify/check_indexes.py new file mode 100644 index 0000000..4f92bcc --- /dev/null +++ b/.tmp-energy-verify/check_indexes.py @@ -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() diff --git a/.tmp-energy-verify/check_timezone.py b/.tmp-energy-verify/check_timezone.py new file mode 100644 index 0000000..b2b5574 --- /dev/null +++ b/.tmp-energy-verify/check_timezone.py @@ -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() diff --git a/.tmp-energy-verify/cleanup_output.txt b/.tmp-energy-verify/cleanup_output.txt new file mode 100644 index 0000000..8baaa4a --- /dev/null +++ b/.tmp-energy-verify/cleanup_output.txt @@ -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')) + +临时数据清理完成 diff --git a/.tmp-energy-verify/cleanup_temp_data.py b/.tmp-energy-verify/cleanup_temp_data.py new file mode 100644 index 0000000..fcf6218 --- /dev/null +++ b/.tmp-energy-verify/cleanup_temp_data.py @@ -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临时数据清理完成") diff --git a/.tmp-energy-verify/export_xlsx_content.txt b/.tmp-energy-verify/export_xlsx_content.txt new file mode 100644 index 0000000..016f81f --- /dev/null +++ b/.tmp-energy-verify/export_xlsx_content.txt @@ -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) diff --git a/.tmp-energy-verify/exports/能耗统计_20260919-20260920.xlsx b/.tmp-energy-verify/exports/能耗统计_20260919-20260920.xlsx new file mode 100644 index 0000000..b59a866 Binary files /dev/null and b/.tmp-energy-verify/exports/能耗统计_20260919-20260920.xlsx differ diff --git a/.tmp-energy-verify/facts.txt b/.tmp-energy-verify/facts.txt new file mode 100644 index 0000000..e6ad001 --- /dev/null +++ b/.tmp-energy-verify/facts.txt @@ -0,0 +1,2 @@ +SUPER=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjYzMTUsImlhdCI6MTc4OTgzOTkxNX0.36ocLLksl-sZ_PK8M1mI8M9i9NOq0OcYv_xOdz2iCQA +ORG1=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjYzMTYsImlhdCI6MTc4OTgzOTkxNn0.OpOgTeYh9UdjReUVLh698TN9OOjmuPMxbao8LBZHQDM diff --git a/.tmp-energy-verify/index_check.txt b/.tmp-energy-verify/index_check.txt new file mode 100644 index 0000000..1150a7a --- /dev/null +++ b/.tmp-energy-verify/index_check.txt @@ -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 列) diff --git a/.tmp-energy-verify/isolation_org1.txt b/.tmp-energy-verify/isolation_org1.txt new file mode 100644 index 0000000..d88ed4a --- /dev/null +++ b/.tmp-energy-verify/isolation_org1.txt @@ -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] diff --git a/.tmp-energy-verify/isolation_super.txt b/.tmp-energy-verify/isolation_super.txt new file mode 100644 index 0000000..38c0481 --- /dev/null +++ b/.tmp-energy-verify/isolation_super.txt @@ -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] diff --git a/.tmp-energy-verify/make_role_tokens.py b/.tmp-energy-verify/make_role_tokens.py new file mode 100644 index 0000000..810e57c --- /dev/null +++ b/.tmp-energy-verify/make_role_tokens.py @@ -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()) diff --git a/.tmp-energy-verify/setup_orgB.py b/.tmp-energy-verify/setup_orgB.py new file mode 100644 index 0000000..7a6d0fc --- /dev/null +++ b/.tmp-energy-verify/setup_orgB.py @@ -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() diff --git a/.tmp-energy-verify/setup_temp_data.py b/.tmp-energy-verify/setup_temp_data.py new file mode 100644 index 0000000..a9d36c1 --- /dev/null +++ b/.tmp-energy-verify/setup_temp_data.py @@ -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() diff --git a/.tmp-energy-verify/sql_verify_output.txt b/.tmp-energy-verify/sql_verify_output.txt new file mode 100644 index 0000000..192c6c9 --- /dev/null +++ b/.tmp-energy-verify/sql_verify_output.txt @@ -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 行 diff --git a/.tmp-energy-verify/temp_ids.json b/.tmp-energy-verify/temp_ids.json new file mode 100644 index 0000000..c26018a --- /dev/null +++ b/.tmp-energy-verify/temp_ids.json @@ -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 + ] +} \ No newline at end of file diff --git a/.tmp-energy-verify/temp_ids_orgB.json b/.tmp-energy-verify/temp_ids_orgB.json new file mode 100644 index 0000000..ba466f1 --- /dev/null +++ b/.tmp-energy-verify/temp_ids_orgB.json @@ -0,0 +1,8 @@ +{ + "org_id": 4, + "project_id": 3, + "cabinet_id": 4, + "board_id": 5, + "compartment_id": 7, + "record_id": 16 +} \ No newline at end of file diff --git a/.tmp-energy-verify/timezone_check.txt b/.tmp-energy-verify/timezone_check.txt new file mode 100644 index 0000000..4d7c7fb --- /dev/null +++ b/.tmp-energy-verify/timezone_check.txt @@ -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))) diff --git a/.tmp-energy-verify/token.txt b/.tmp-energy-verify/token.txt new file mode 100644 index 0000000..bfe9433 --- /dev/null +++ b/.tmp-energy-verify/token.txt @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5NDYsImlhdCI6MTc4OTgzOTU0Nn0.X-T6uBTENj9UK9seDIyyJJXopJcNJfSfDZ1C60AP-yA \ No newline at end of file diff --git a/.tmp-energy-verify/token_local.txt b/.tmp-energy-verify/token_local.txt new file mode 100644 index 0000000..8e63930 --- /dev/null +++ b/.tmp-energy-verify/token_local.txt @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5ODksImlhdCI6MTc4OTgzOTU4OX0.ZiKHmR2fKvfSLaINjDq_R9npcFldGqP4WOQessAotxg \ No newline at end of file diff --git a/.tmp-energy-verify/token_testenv.txt b/.tmp-energy-verify/token_testenv.txt new file mode 100644 index 0000000..1f88283 --- /dev/null +++ b/.tmp-energy-verify/token_testenv.txt @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsInBob25lIjoiMTk5MDAwMDAwMDEiLCJleHAiOjE3ODk5MjU5ODksImlhdCI6MTc4OTgzOTU4OX0.Y0Tju_TsC07S-qyIhD4cRpT8I1uqzTuCGWVH6QOorJM \ No newline at end of file diff --git a/.tmp-energy-verify/verify_endpoints.sh b/.tmp-energy-verify/verify_endpoints.sh new file mode 100644 index 0000000..c9c42d9 --- /dev/null +++ b/.tmp-energy-verify/verify_endpoints.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# 能耗报表接口验证脚本(在测试服务器上执行,直连本机 :3000,绕开 nginx) +# 用法: bash verify_endpoints.sh [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" diff --git a/.tmp-energy-verify/verify_live_testenv.py b/.tmp-energy-verify/verify_live_testenv.py new file mode 100644 index 0000000..9bdbaa4 --- /dev/null +++ b/.tmp-energy-verify/verify_live_testenv.py @@ -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() diff --git a/.tmp-energy-verify/verify_live_testenv_output.txt b/.tmp-energy-verify/verify_live_testenv_output.txt new file mode 100644 index 0000000..e07c138 --- /dev/null +++ b/.tmp-energy-verify/verify_live_testenv_output.txt @@ -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 diff --git a/.tmp-energy-verify/verify_new_binary.txt b/.tmp-energy-verify/verify_new_binary.txt new file mode 100644 index 0000000..1b2dd78 --- /dev/null +++ b/.tmp-energy-verify/verify_new_binary.txt @@ -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] diff --git a/.tmp-energy-verify/verify_sql.py b/.tmp-energy-verify/verify_sql.py new file mode 100644 index 0000000..70ed880 --- /dev/null +++ b/.tmp-energy-verify/verify_sql.py @@ -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() diff --git a/.tmp-schema-align/__pycache__/inspect.cpython-314.pyc b/.tmp-schema-align/__pycache__/inspect.cpython-314.pyc new file mode 100644 index 0000000..44c5b58 Binary files /dev/null and b/.tmp-schema-align/__pycache__/inspect.cpython-314.pyc differ diff --git a/.tmp-schema-align/cargo-check.txt b/.tmp-schema-align/cargo-check.txt new file mode 100644 index 0000000..34694d3 --- /dev/null +++ b/.tmp-schema-align/cargo-check.txt @@ -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 diff --git a/.tmp-schema-align/cargo-check2.txt b/.tmp-schema-align/cargo-check2.txt new file mode 100644 index 0000000..a600461 --- /dev/null +++ b/.tmp-schema-align/cargo-check2.txt @@ -0,0 +1 @@ + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.80s diff --git a/.tmp-schema-align/cargo-test.txt b/.tmp-schema-align/cargo-test.txt new file mode 100644 index 0000000..a45b690 --- /dev/null +++ b/.tmp-schema-align/cargo-test.txt @@ -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 + diff --git a/.tmp-schema-align/cargo-test2.txt b/.tmp-schema-align/cargo-test2.txt new file mode 100644 index 0000000..aff0956 --- /dev/null +++ b/.tmp-schema-align/cargo-test2.txt @@ -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 + diff --git a/.tmp-schema-align/compare.py b/.tmp-schema-align/compare.py new file mode 100644 index 0000000..0bb68f4 --- /dev/null +++ b/.tmp-schema-align/compare.py @@ -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),''), + 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 ("",) * 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)) diff --git a/.tmp-schema-align/compare_full.py b/.tmp-schema-align/compare_full.py new file mode 100644 index 0000000..c0fe96d --- /dev/null +++ b/.tmp-schema-align/compare_full.py @@ -0,0 +1,58 @@ +"""只读比对脚本:逐列比对 pms_dev(测试) 与 pms(生产) 的**全部**列定义 + +比对维度:COLUMN_TYPE / IS_NULLABLE / COLUMN_DEFAULT / EXTRA / COLUMN_COMMENT +另外单独统计两库是否还残留 `TIMESTAMP` 列(本项目禁止:sqlx 只能把它解码成 DateTime, +而 Rust 侧时间列全是 NaiveDateTime/Option ⇒ 接口 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),''), + 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])) diff --git a/.tmp-schema-align/compare_time.py b/.tmp-schema-align/compare_time.py new file mode 100644 index 0000000..3572e2e --- /dev/null +++ b/.tmp-schema-align/compare_time.py @@ -0,0 +1,42 @@ +"""列出两库全部时间列的类型,并做差异比对(只读)""" +import pymysql + +SQL = """ +SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, + IFNULL(CAST(COLUMN_DEFAULT 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], 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, ("",) * 3), prd.get(k, ("",) * 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]) diff --git a/.tmp-schema-align/dev_sql.py b/.tmp-schema-align/dev_sql.py new file mode 100644 index 0000000..fc6ab3d --- /dev/null +++ b/.tmp-schema-align/dev_sql.py @@ -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) diff --git a/.tmp-schema-align/diff_detail.py b/.tmp-schema-align/diff_detail.py new file mode 100644 index 0000000..0e72eb3 --- /dev/null +++ b/.tmp-schema-align/diff_detail.py @@ -0,0 +1,41 @@ +"""列出两库时间列的完整定义(含 EXTRA),用于生成对齐清单(只读)""" +import pymysql + +SQL = """ +SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, + IFNULL(CAST(COLUMN_DEFAULT AS CHAR),''), + 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])) diff --git a/.tmp-schema-align/dryrun.out.txt b/.tmp-schema-align/dryrun.out.txt new file mode 100644 index 0000000..446af21 --- /dev/null +++ b/.tmp-schema-align/dryrun.out.txt @@ -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 root@118.31.119.206 "systemctl stop pms-api pms-device && cp /data/pms/backup-/* /data/pms/... && systemctl start pms-api pms-device" + 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 5 次) diff --git a/.tmp-schema-align/endpoint_verify.py b/.tmp-schema-align/endpoint_verify.py new file mode 100644 index 0000000..3179f41 --- /dev/null +++ b/.tmp-schema-align/endpoint_verify.py @@ -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() diff --git a/.tmp-schema-align/export_probe.py b/.tmp-schema-align/export_probe.py new file mode 100644 index 0000000..cac08c4 --- /dev/null +++ b/.tmp-schema-align/export_probe.py @@ -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)) diff --git a/.tmp-schema-align/export_verify.py b/.tmp-schema-align/export_verify.py new file mode 100644 index 0000000..dfa8f63 --- /dev/null +++ b/.tmp-schema-align/export_verify.py @@ -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) diff --git a/.tmp-schema-align/extract_step8.ps1 b/.tmp-schema-align/extract_step8.ps1 new file mode 100644 index 0000000..5c38edf --- /dev/null +++ b/.tmp-schema-align/extract_step8.ps1 @@ -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)" diff --git a/.tmp-schema-align/leftover_check.py b/.tmp-schema-align/leftover_check.py new file mode 100644 index 0000000..9ea2bfd --- /dev/null +++ b/.tmp-schema-align/leftover_check.py @@ -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() diff --git a/.tmp-schema-align/probe1.py b/.tmp-schema-align/probe1.py new file mode 100644 index 0000000..fd22049 --- /dev/null +++ b/.tmp-schema-align/probe1.py @@ -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() diff --git a/.tmp-schema-align/probe2.py b/.tmp-schema-align/probe2.py new file mode 100644 index 0000000..c1011b0 --- /dev/null +++ b/.tmp-schema-align/probe2.py @@ -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),'') 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]) diff --git a/.tmp-schema-align/probe3.py b/.tmp-schema-align/probe3.py new file mode 100644 index 0000000..84e282a --- /dev/null +++ b/.tmp-schema-align/probe3.py @@ -0,0 +1,18 @@ +import pymysql + +SQL = """ +SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, + IFNULL(CAST(COLUMN_DEFAULT AS CHAR),'') 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() diff --git a/.tmp-schema-align/prod_probe.py b/.tmp-schema-align/prod_probe.py new file mode 100644 index 0000000..3addbfb --- /dev/null +++ b/.tmp-schema-align/prod_probe.py @@ -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)) diff --git a/.tmp-schema-align/remote-backup.sh b/.tmp-schema-align/remote-backup.sh new file mode 100644 index 0000000..8505905 --- /dev/null +++ b/.tmp-schema-align/remote-backup.sh @@ -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 diff --git a/.tmp-schema-align/remote-backup2.sh b/.tmp-schema-align/remote-backup2.sh new file mode 100644 index 0000000..5c4cc1a --- /dev/null +++ b/.tmp-schema-align/remote-backup2.sh @@ -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" diff --git a/.tmp-schema-align/remote-backup3.sh b/.tmp-schema-align/remote-backup3.sh new file mode 100644 index 0000000..e74c90b --- /dev/null +++ b/.tmp-schema-align/remote-backup3.sh @@ -0,0 +1 @@ +/usr/local/bin/pms-backup-db.sh diff --git a/.tmp-schema-align/remote-retention-test.sh b/.tmp-schema-align/remote-retention-test.sh new file mode 100644 index 0000000..58a6bc1 --- /dev/null +++ b/.tmp-schema-align/remote-retention-test.sh @@ -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 diff --git a/.tmp-schema-align/remote1.sh b/.tmp-schema-align/remote1.sh new file mode 100644 index 0000000..75b9d33 --- /dev/null +++ b/.tmp-schema-align/remote1.sh @@ -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" diff --git a/.tmp-schema-align/remote10.sh b/.tmp-schema-align/remote10.sh new file mode 100644 index 0000000..2c9add5 --- /dev/null +++ b/.tmp-schema-align/remote10.sh @@ -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' ' diff --git a/.tmp-schema-align/remote2.sh b/.tmp-schema-align/remote2.sh new file mode 100644 index 0000000..82841a1 --- /dev/null +++ b/.tmp-schema-align/remote2.sh @@ -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" diff --git a/.tmp-schema-align/remote3.sh b/.tmp-schema-align/remote3.sh new file mode 100644 index 0000000..a5f15c7 --- /dev/null +++ b/.tmp-schema-align/remote3.sh @@ -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 diff --git a/.tmp-schema-align/remote4.sh b/.tmp-schema-align/remote4.sh new file mode 100644 index 0000000..9d9de7d --- /dev/null +++ b/.tmp-schema-align/remote4.sh @@ -0,0 +1 @@ +journalctl -u pms-api-test --since '-2 min' --no-pager | grep -iE 'ERROR' | tail -6 diff --git a/.tmp-schema-align/remote5.sh b/.tmp-schema-align/remote5.sh new file mode 100644 index 0000000..c983318 --- /dev/null +++ b/.tmp-schema-align/remote5.sh @@ -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 diff --git a/.tmp-schema-align/remote6.sh b/.tmp-schema-align/remote6.sh new file mode 100644 index 0000000..ca03d75 --- /dev/null +++ b/.tmp-schema-align/remote6.sh @@ -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 diff --git a/.tmp-schema-align/remote7.sh b/.tmp-schema-align/remote7.sh new file mode 100644 index 0000000..345b074 --- /dev/null +++ b/.tmp-schema-align/remote7.sh @@ -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/dev/null || echo " /tmp 已无本次临时文件" diff --git a/.tmp-schema-align/remote9.sh b/.tmp-schema-align/remote9.sh new file mode 100644 index 0000000..34291f8 --- /dev/null +++ b/.tmp-schema-align/remote9.sh @@ -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" diff --git a/.tmp-schema-align/retention.sh b/.tmp-schema-align/retention.sh new file mode 100644 index 0000000..58f4d00 --- /dev/null +++ b/.tmp-schema-align/retention.sh @@ -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)" \ No newline at end of file diff --git a/.tmp-schema-align/run-dev/run1.err.txt b/.tmp-schema-align/run-dev/run1.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-dev/run1.out.txt b/.tmp-schema-align/run-dev/run1.out.txt new file mode 100644 index 0000000..f7f834b --- /dev/null +++ b/.tmp-schema-align/run-dev/run1.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-dev/run2.err.txt b/.tmp-schema-align/run-dev/run2.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-dev/run2.out.txt b/.tmp-schema-align/run-dev/run2.out.txt new file mode 100644 index 0000000..99226c6 --- /dev/null +++ b/.tmp-schema-align/run-dev/run2.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-dev/run3.err.txt b/.tmp-schema-align/run-dev/run3.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-dev/run3.out.txt b/.tmp-schema-align/run-dev/run3.out.txt new file mode 100644 index 0000000..bca4ff6 --- /dev/null +++ b/.tmp-schema-align/run-dev/run3.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-dev/run4.err.txt b/.tmp-schema-align/run-dev/run4.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-dev/run4.out.txt b/.tmp-schema-align/run-dev/run4.out.txt new file mode 100644 index 0000000..e697385 --- /dev/null +++ b/.tmp-schema-align/run-dev/run4.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-dev/run5.err.txt b/.tmp-schema-align/run-dev/run5.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-dev/run5.out.txt b/.tmp-schema-align/run-dev/run5.out.txt new file mode 100644 index 0000000..8da061e --- /dev/null +++ b/.tmp-schema-align/run-dev/run5.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-dev/run6.err.txt b/.tmp-schema-align/run-dev/run6.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-dev/run6.out.txt b/.tmp-schema-align/run-dev/run6.out.txt new file mode 100644 index 0000000..6ac4a97 --- /dev/null +++ b/.tmp-schema-align/run-dev/run6.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-dev/run7.err.txt b/.tmp-schema-align/run-dev/run7.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-dev/run7.out.txt b/.tmp-schema-align/run-dev/run7.out.txt new file mode 100644 index 0000000..63a8b01 --- /dev/null +++ b/.tmp-schema-align/run-dev/run7.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-prod/prod-run1.err.txt b/.tmp-schema-align/run-prod/prod-run1.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-prod/prod-run1.out.txt b/.tmp-schema-align/run-prod/prod-run1.out.txt new file mode 100644 index 0000000..5ae1ea9 --- /dev/null +++ b/.tmp-schema-align/run-prod/prod-run1.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-prod/prod-run2.err.txt b/.tmp-schema-align/run-prod/prod-run2.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-prod/prod-run2.out.txt b/.tmp-schema-align/run-prod/prod-run2.out.txt new file mode 100644 index 0000000..6c76497 --- /dev/null +++ b/.tmp-schema-align/run-prod/prod-run2.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-prod/prod-run3.err.txt b/.tmp-schema-align/run-prod/prod-run3.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-prod/prod-run3.out.txt b/.tmp-schema-align/run-prod/prod-run3.out.txt new file mode 100644 index 0000000..3e478ac --- /dev/null +++ b/.tmp-schema-align/run-prod/prod-run3.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-prod/prod-run4.err.txt b/.tmp-schema-align/run-prod/prod-run4.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-prod/prod-run4.out.txt b/.tmp-schema-align/run-prod/prod-run4.out.txt new file mode 100644 index 0000000..5078811 --- /dev/null +++ b/.tmp-schema-align/run-prod/prod-run4.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-prod/prod-run5.err.txt b/.tmp-schema-align/run-prod/prod-run5.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-prod/prod-run5.out.txt b/.tmp-schema-align/run-prod/prod-run5.out.txt new file mode 100644 index 0000000..fd4f634 --- /dev/null +++ b/.tmp-schema-align/run-prod/prod-run5.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-schema-align/run-prod/prod-run6.err.txt b/.tmp-schema-align/run-prod/prod-run6.err.txt new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-schema-align/run-prod/prod-run6.out.txt b/.tmp-schema-align/run-prod/prod-run6.out.txt new file mode 100644 index 0000000..78f81a0 --- /dev/null +++ b/.tmp-schema-align/run-prod/prod-run6.out.txt @@ -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: 数据库迁移完成 diff --git a/.tmp-scope-check/ddl_before.json b/.tmp-scope-check/ddl_before.json new file mode 100644 index 0000000..72900ef --- /dev/null +++ b/.tmp-scope-check/ddl_before.json @@ -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" +} \ No newline at end of file diff --git a/.tmp-scope-check/scope_isolation_check.py b/.tmp-scope-check/scope_isolation_check.py new file mode 100644 index 0000000..ed392f6 --- /dev/null +++ b/.tmp-scope-check/scope_isolation_check.py @@ -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() diff --git a/AGENTS.md b/AGENTS.md index 6a20d52..2f42753 100644 --- a/AGENTS.md +++ b/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 ``` +**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次)**: + +```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` 兼容,用错就是接口 500)。 + +端到端验收(会造临时行并自动清理,需该环境的 `JWT_SECRET`): + +```bash +python test/schema_alignment_e2e.py --env dev --jwt-secret +python test/schema_alignment_e2e.py --env prod --jwt-secret --yes-prod +``` + +> ⚠️ **2026-09-20 生产结构变更(行为变化,非 bug)**:`organizations`、`projects`、`cabinets`、 +> `cabin_boards`、`compartments`、`users`、`roles` 这 **7 张表的 `updated_at` 恢复为 +> `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`** +> —— 此前它们是"人工从 `TIMESTAMP` 改成 `DATETIME` 时漏掉了 `ON UPDATE`"的偏离状态, +> 所以**这些表的 `updated_at` 从"不会自动刷新"变成"随 `UPDATE` 自动刷新"**, +> 看到 `updated_at` 变动属预期行为。同批变更还把两库全部时间列统一成 `DATETIME` +> (详见 `software/api-server/src/db/migrate.rs` 的 `LEGACY_COLUMN_TARGETS`)。 + ### 证书 - 阿里云免费SSL证书(acme.sh自动续期) @@ -385,7 +417,14 @@ test_check_permission_user_returns_forbidden - RBAC 权限码:精确到按钮级别(如 `device:door:unlock`) - 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免 - 敏感操作(删除/禁用/开门)需二次确认 -- TCP 连接认证:设备 login 成功(响应含 `spec` + `suc=1`)前,敏感指令(open/pow_on/pow_off/reset/auth_str)不投递 +- **TCP 连接登录门控(上行 + 下行,同一规则)**:同一条 TCP 连接在 `login` 成功前**只允许 `auth_str` / `login`**; + 其余任何 `act`(含 `status_post`、`bat_in`、`on`/`off`、`open`/`pow_on`/`pow_off`/`reset`、以及各类应答) + **上行一律丢弃、下行一律不投递**(设备侧收不到任何字节)。 + 依据:`docs/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。 + **判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`, + 在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位), + **不是** Redis 在线键 `device:{imei}` —— 该键在验签前就会被写,用它判在线必然误判。 + ⛔ 不要再按"某个 `act` 算不算敏感指令"来增删名单:规则是"登录前只有 `auth_str`/`login`"(常量 `PRE_LOGIN_ALLOWED_ACTS`)。 - 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`) - **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`** - **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定 @@ -755,6 +794,67 @@ tail -f /var/log/nginx/error.log 设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。 +### 层级与可见性(**全系统统一口径,所有组织隔离改动一律以此为准**) + +``` +柜子工厂(我们,总管理员 role_level=2) + └── 客户组织(每个客户一个 organization) + └── 客户在自己组织下新建的操作员 / 组织管理员 +``` + +| 角色 | `users.role`(role_level) | 可见组织范围 | +|------|---------------------------|-------------| +| 总管理员(工厂) | `2` | **全部组织**(跨组织视角,小程序「选择组织」弹层可切换,默认「全部」) | +| 组织管理员(客户) | `1` | **仅自己所属组织**(按 `users.organization_id`) | +| 操作员(客户) | `0` | **仅自己所属组织**(按 `users.organization_id`) | + +- **归属链**:客户侧用户的组织归属只认 `users.organization_id`;`organization_id` 为 `NULL` 的客户侧账号**看不到任何组织/设备**(空集,不是报错,也**绝不退化成"全部"**)。 +- **两条组织接口的分工(不可混用)**: + - `GET /api/organizations` —— **管理端**(PC)组织管理页,硬校验 `org:view`,返回**全量**组织(操作员无该权限码 → 403)。语义冻结,不得改动。 + - `GET /api/my-organizations` —— **小程序**「选择组织」专用,**仅需登录**(不校验 `org:view`),服务端按上表做隔离:工厂全量、客户仅本组织。 +- 设备/项目/能耗/日志等所有业务数据的组织隔离,一律按「`role_level >= 2` 放行全量,否则按 `organization_id` 过滤,无组织则空集」实现(例:`routes/h5.rs::visible_org_scope`、`middleware/auth.rs::org_condition`)。 + +### 数据范围(user_scopes)—— **组织隔离之上的第二道收窄** + +> 权威实现:`software/api-server/src/middleware/auth.rs`(`UserScope` / `load_user_scopes` / +> `CurrentUser::effective_scope`)、`routes/scope_guard.rs`(详情/写操作)、`routes/users_perm.rs`(配置校验)。 +> 前端入口:**PC 用户管理页 → 操作列「数据范围」**(`web/src/components/UserDataScopeModal.tsx`)。 + +**范围粒度到「项目级」**(`user_scopes.scope_type`): + +| `scope_type` | 含义 | 是否参与鉴权 | +|---|---|---| +| `1` | 组织级 | ✅ 参与。**只允许配成该用户自己所属的组织**,展开为该组织下的全部项目 | +| `2` | 项目级 | ✅ 参与。**实际生效的粒度** | +| `3` | 设备/柜子级 | ❌ **不参与鉴权**(当不存在,读取时直接忽略;写入仍做存在性+归属校验后存档) | + +**四种情形的行为(必须严格遵守,改动前先读这一节):** + +| 情形 | 行为 | +|---|---| +| **未配置任何 scope 记录** | **不限制**:沿用组织隔离,可见范围与"没做这个功能"时**逐字一致**。⛔ 绝不能解释成"无权限" —— 现存所有客户账号都没配过,那样会一次锁死全部账号 | +| **已配置(≥1 条有效)** | 在组织隔离基础上**再收窄**:`组织隔离 ∧ 项目∈范围`(**取交集,绝不取并集、绝不放大**) | +| **总管理员(`role_level >= 2`)** | **豁免**:始终可见全量,不受 scope 限制(`CurrentUser::effective_scope` 恒返回"不限制",中间件连库都不查) | +| **有记录但一条有效范围都没有**(脏数据 / 只有设备级 / 跨组织 / 项目已删除) | 保守忽略无效行;若因此一条有效范围都不剩 ⇒ 按**未配置 = 不限制**处理,并记 `WARN`(**绝不把用户锁死**) | + +**`scope_id` 校验(配置期,`PUT /api/users/{id}/scopes`)**: +- 资源必须**存在**,且**属于该用户所在组织**,否则 `BadRequest`(400); + - 组织级:`scope_id` 必须等于目标用户的 `users.organization_id`(**不允许跨组织配置**); + - 项目级:`projects.organization_id` 必须等于目标用户的 `users.organization_id`; + - 设备级:柜子所属项目的组织必须等于目标用户的 `users.organization_id`(存档但不消费)。 +- 全量替换在**事务**里执行:校验全部通过前不删旧数据,避免中途失败留下"范围被清空"的中间态。 +- 目标用户 `organization_id` 为 `NULL`(客户侧无组织账号)时,任何带归属的范围都不合法 ⇒ 一律 400。 + +**鉴权侧如何消费(改动任何列表/详情接口前必须核对):** +- 过滤统一落在**项目**这一层(`cabinets.project_id` / `projects.id`),只有一种收窄形态: + `project_id IN (...)`(柜子维度用 `cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (...))`); +- 列表接口:在**已有的组织条件之后**用 `&&` 追加范围条件(`auth::append_condition`); +- 详情/写操作:走 `routes/scope_guard.rs`(或 h5 的 `verify_cabinet_access`),组织校验通过后再校验资源是否落在范围内,不在则 `Forbidden`; +- 后台任务(Excel 导出)必须把 `scope_project_ids` 一并落库到任务参数里,否则后台任务会绕过范围; +- ⚠️ **配了 scope 的用户被漏叠加的接口 = 数据泄露**。新增任何业务列表/详情接口时,同时叠加组织条件与范围条件。 + +**操作日志不叠加范围**(经确认,非遗漏):`operation_logs` 按 `user_id → users.organization_id` 归属,与项目/设备没有关联列,项目级范围无法映射。 + ## 数据模型 ### 核心关系 @@ -939,6 +1039,40 @@ HSET device:{imei} online 1 rssi 85 2. **通道统计**:图标+数字一行排列,图标用 `` 内联 Lucide(不加容器,flex 居中) 3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐 +### 状态位展示约定(**状态位按归属层级展示**) + +> 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。 + +1. **柜级状态位 → 显示在"柜子"层面** + - 数据:`cabinet.status1` / `cabinet.status2`(2 位 hex,**设备上报原样落 Redis** + `device:{imei}.cabinet_status1/2`,接口原样透出为 `status1` / `status2`)。 + - 位定义(§3.8):`status1` bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|**bit4 柜门打开**| + bit5 加热风扇|bit6 接触器断开|bit7 喷淋;`status2` bit0 声光报警|**bit1 柜门打开超时** + (阈值硬件定义,**平台只呈现、不自行判定**);`status2` bit2–7 为保留位,**不展示**。 + - 页面:PC `web/src/pages/devices/cabinet.tsx`、小程序 `h5-uniapp/src/pages/device-detail/index.vue`。 + - **取不到返回 `null`(未知),绝不落 `"00"`** —— `"00"` 是"全部正常"的有效值。 +2. **仓体级状态位 → 显示在"仓体"层面** + - 数据:通道 18 字符 hex 的 `batt_status`(byte0) / `pow_status`(byte1);保留位 + (`batt_status` bit2–3、`pow_status` bit3–6)**不展示**。 + - 页面:PC 柜子详情里的通道卡片、小程序 `pages/compartment-detail/index.vue`。 + - **仓体层面不显示任何"门"状态**:协议**没有**"按仓体(舱门)开门"的指令(§4.2 L403)。 +3. **柜门是柜级独立动作,不与业务联动,全平台只有一个开门按钮** + - `open` 是**柜级**指令(**不带 `id`**),语义是**「柜门解锁,下发后由人拉开,门不会自动开」**; + 没有"关门"指令(关门也是人为动作)。**全平台只有一扇柜门**,不按仓体区分。 + - **柜门状态永远来自 `status_post`(约 60 秒一次),不是实时量** —— 界面上必须标注 + "来自最近一次上报(约 60 秒一次),非实时"(§4.2 L428)。 + - 开门是**独立流程**,与电池插入、充电开始/结束**没有因果关系也没有先后要求**(§5.7、§7.2): + 不得出现在"开始充电"流程里、不得改任何订单状态、不得要求存在订单。 + - 入口只有一个:PC 详情页按钮(`` 包裹)+ + 小程序设备详情页按钮;**仓体(通道)详情页不放开门按钮**。 + - 权限码 `charge:open_door`(后端 `check_permission` 硬校验;`device:door:unlock` 是历史误用的 + 不存在权限码,**禁止再用**)。 +4. **解析函数两端必须同源同名**:`web/src/utils/deviceStatus.ts` 与 + `h5-uniapp/src/utils/deviceStatus.ts` 各有一组 `parseCabinetStatus1` / `parseCabinetStatus2` / + `cabinetDoorState` / `cabinetStatusTags`(注释互相指认对方文件,改一边务必同步另一边)。 + 端到端一致性用例见 `test/device_status_parse.test.mjs`(`node test/device_status_parse.test.mjs`, + 逐位遍历 0x00–0xFF 断言两端一致)。 + ### 开发约束(uni-app 特定) 1. **`` 不支持 `display: flex`** — uni-app 的 `` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `` diff --git a/README.md b/README.md index f188222..0abf522 100644 --- a/README.md +++ b/README.md @@ -89,18 +89,8 @@ charging-cabinet/ ## TCP 设备通讯协议 -> 完整协议文档见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,唯一权威 TCP 文档) - -- TCP 长连接,空闲 60 秒/充电 15 秒上报 -- 所有设备上报消息平台都会回复 `{"act":"<同请求>","msg_id":N,"suc":1}` 确认 -- 响应扁平化,不加 `data` 包裹 -- 签名机制:`SHA256(dev_id + auth_str + timestamp)`,取 `hex[56..64]`,**8 位**小写十六进制 -- `id` 字段:仓体级 `IMEI-板-通道`,柜级 `IMEI` -- 全局整数单位:voltage mV, current mA, temp K×100, soc ‰ - -**11 条指令:** login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off / open / pow_on / pow_off / reset - -完整请求/响应示例、字段表、签名自测向量、超时与自愈流程 → [docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md) +> TCP 设备通讯协议见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。 +> 全局整数单位(voltage mV、current mA、temp K×100、soc ‰)见 [docs/API.md](docs/API.md#数据单位规范)。 - 任务需求写到 `tasks/` 目录的md文件 - 通过Agent子代理包裹opencode执行,异步通知 diff --git a/deploy/deploy-prod.ps1 b/deploy/deploy-prod.ps1 index 1f1262f..04fa434 100644 --- a/deploy/deploy-prod.ps1 +++ b/deploy/deploy-prod.ps1 @@ -10,7 +10,7 @@ # 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错, # 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂) # 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收" -# 的顺序执行,并在收尾打印回滚方法。 +# 的顺序执行,并在收尾打印回滚方法;最后按"只保留最近 5 次回滚点"清理历史 `backup-*`。 # # 用法: # pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收) @@ -34,6 +34,9 @@ $Dst = '/data/pms' $ApiPort = 9000 $TcpPort = 9001 $Domain = 'app.anzhizhichong.com' +# 回滚点(backup-YYYYMMDD-HHMMSS)保留份数:每次部署新建一份,从不清理会持续涨 +# (实测 2026-09-20 已累计 143MB / 4 份,单份最大 99MB)。保留最近 N 次即可回滚。 +$KeepBackups = 5 # ---- 路径 ------------------------------------------------------------------- $Repo = Split-Path -Parent $PSScriptRoot @@ -57,7 +60,7 @@ function Send-File([string]$local, [string]$remote) { # ---- 1. 构建 --------------------------------------------------------------- if (-not $SkipBuild) { - Step '1/7 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)' + Step '1/8 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)' Say 'api-server ...' if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/api-server/Cargo.toml') } Say 'device-server ...' @@ -69,7 +72,7 @@ if (-not $SkipBuild) { } # ---- 2. 校验产物 + 静态检查 ------------------------------------------------ -Step '2/7 校验产物与静态检查' +Step '2/8 校验产物与静态检查' foreach ($f in @($ApiBin, $DevBin)) { if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" } Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB)) @@ -80,7 +83,7 @@ if (-not $SkipWeb) { } # ---- 3. 服务器备份(可回滚)------------------------------------------------ -Step '3/7 服务器备份(保留旧二进制/.env/nginx/单元/前端)' +Step '3/8 服务器备份(保留旧二进制/.env/nginx/单元/前端)' Invoke-Remote @" set -e TS=`$(date +%Y%m%d-%H%M%S) @@ -94,7 +97,7 @@ echo " 备份目录: $Dst/backup-`$TS" "@ # ---- 4. 上传(临时名,避免 ETXTBSY)---------------------------------------- -Step '4/7 上传(二进制传 .new,运行中的二进制不可直接覆盖)' +Step '4/8 上传(二进制传 .new,运行中的二进制不可直接覆盖)' Send-File $ApiBin "$Dst/api-server/pms-api.new" Send-File $DevBin "$Dst/device-server/pms-device-server.new" if (-not $SkipWeb) { @@ -104,7 +107,7 @@ if (-not $SkipWeb) { } # ---- 5. 切换 + 重启 -------------------------------------------------------- -Step '5/7 原子替换并重启服务' +Step '5/8 原子替换并重启服务' Invoke-Remote @" set -e chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new @@ -119,7 +122,7 @@ nginx -t && systemctl reload nginx "@ # ---- 6. 验收 --------------------------------------------------------------- -Step '6/7 验收(服务/端口/域名/日志)' +Step '6/8 验收(服务/端口/域名/日志)' Invoke-Remote @" echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' ' echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}' @@ -133,7 +136,7 @@ journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^ "@ # ---- 7. 消费链路探测(无副作用)------------------------------------------- -Step '7/7 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)' +Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)' if (-not $DryRun) { $probe = @' import redis, json, time @@ -147,10 +150,72 @@ print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消 python "$env:TEMP\pms_probe.py" } +# ---- 8. 回滚点保留策略(只保留最近 N 次 backup-YYYYMMDD-HHMMSS)-------------- +# 为什么放在最后:本步骤在**验收通过之后**才执行 —— 中途任何一步 throw 都会在这里 +# 之前退出,本次及历史的回滚点全部保留(部署失败时最需要回滚点)。 +# +# ⚠️ 匹配规则必须**严格**:只认「名字完全等于 backup-8位数字-6位数字」的**目录**。 +# /data/pms 下还存在 `backup-20260920-004848-api.env`(文件名带后缀)这类条目, +# 通配 backup-* 会把它一起卷进来 —— 那是**数据/配置备份,不是回滚点,绝不能删**。 +# 因此这里用 glob 字符类 + `-d` 判定 + 删除前 `case` 复核,三重限定。 +Step "8/8 回滚点保留策略(只保留最近 $KeepBackups 次 backup-YYYYMMDD-HHMMSS)" +Invoke-Remote @" +set -e +shopt -s nullglob +KEEP=$KeepBackups +# glob 字符类只匹配严格的 backup-YYYYMMDD-HHMMSS;nullglob 让无匹配时展开为空数组。 +# glob 结果按字典序排列,对该命名格式而言等价于时间戳升序(最早的在前)。 +rollbacks=() +for p in $Dst/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do + if [ -d "`$p" ]; then + rollbacks+=( "`$p" ) + fi +done +total=`${#rollbacks[@]} +echo " 当前回滚点 `$total 个(deploy 会为每次部署新建一份):" +for p in "`${rollbacks[@]}"; do + echo " `$p" +done +if [ "`$total" -gt "`$KEEP" ]; then + drop=`$((total - KEEP)) + echo " 将删除最早的 `$drop 个(保留最近 `$KEEP 个):" + i=0 + for p in "`${rollbacks[@]}"; do + if [ "`$i" -ge "`$drop" ]; then + break + fi + i=`$((i + 1)) + # 复核:名字必须严格符合 backup-YYYYMMDD-HHMMSS,否则拒绝删除(绝不碰其它目录) + case "`$(basename "`$p")" in + backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + echo " 删除 `$p" + rm -rf -- "`$p" + ;; + *) + echo " [skip] 名字不符合回滚点模式,拒绝删除: `$p" + ;; + esac + done +else + echo " 回滚点数量未超过 `$KEEP,无需清理" +fi +kept=() +for p in $Dst/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do + if [ -d "`$p" ]; then + kept+=( "`$p" ) + fi +done +echo " 清理后保留的回滚点 `${#kept[@]} 个:" +for p in "`${kept[@]}"; do + echo " `$p" +done +echo " $Dst 占用: `$(du -sh $Dst | cut -f1)" +"@ + Write-Host "`n=== 部署完成 ===" -ForegroundColor Green Say "正式环境:https://$Domain/pms/" Say "API :$ApiPort TCP :$TcpPort" Say '' Say '回滚方法(如需):' Say " plink -batch -pw root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-/* /data/pms/... && systemctl start pms-api pms-device`"" -Say ' 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份)' +Say " 备份目录见 $Dst/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 $KeepBackups 次)" diff --git a/deploy/nginx/app.anzhizhichong.com.conf b/deploy/nginx/app.anzhizhichong.com.conf index ee12de6..42be291 100644 --- a/deploy/nginx/app.anzhizhichong.com.conf +++ b/deploy/nginx/app.anzhizhichong.com.conf @@ -13,6 +13,16 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; + # ===== 安全响应头(2026-09 安全加固)===== + # HSTS:本域强制 HTTPS(暂不加 includeSubDomains,避免影响未知子域) + add_header Strict-Transport-Security "max-age=31536000" always; + # 禁止 MIME 嗅探:/uploads 免认证同源直出,防上传内容被当 HTML/JS 执行 + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # CSP 先 Report-Only 观察(只上报不拦截),确认 SPA 无违规后再考虑收紧为强制 + add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; + # 产品介绍页 root /data/landing; index index.html; @@ -26,6 +36,26 @@ server { client_max_body_size 500m; } + # 登录接口限流(防暴力破解第二道;第一道在 api-server 内按手机号+IP 计数锁定) + location = /api/pms/auth/login { + limit_req zone=auth_login_zone burst=10 nodelay; + proxy_pass http://127.0.0.1:9000/api/auth/login; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 验证码接口限流(免认证且逐像素渲染,是 CPU 放大攻击面) + location = /api/pms/auth/challenge { + limit_req zone=auth_challenge_zone burst=10 nodelay; + proxy_pass http://127.0.0.1:9000/api/auth/challenge; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /api/pms/ { proxy_pass http://127.0.0.1:9000/api/; proxy_set_header Host $host; @@ -35,8 +65,9 @@ server { client_max_body_size 10m; } - # 头像上传文件 + # 头像上传文件(免认证同源直出)——限流;nosniff 由 server 级继承 location /uploads/ { + limit_req zone=uploads_zone burst=60 nodelay; proxy_pass http://127.0.0.1:9000/uploads/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/deploy/nginx/test.anzhizhichong.com.conf b/deploy/nginx/test.anzhizhichong.com.conf index 786eaa3..c2e7bda 100644 --- a/deploy/nginx/test.anzhizhichong.com.conf +++ b/deploy/nginx/test.anzhizhichong.com.conf @@ -8,23 +8,70 @@ server { listen 443 ssl http2; server_name test.anzhizhichong.com; - ssl_certificate /etc/nginx/ssl/test.anzhizhichong.com.pem; - ssl_certificate_key /etc/nginx/ssl/test.anzhizhichong.com.key; + ssl_certificate /etc/nginx/ssl/app.anzhizhichong.com.pem; + ssl_certificate_key /etc/nginx/ssl/app.anzhizhichong.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; + # ===== 安全响应头(2026-09 安全加固)===== + add_header Strict-Transport-Security "max-age=31536000" always; + # 禁止 MIME 嗅探:/uploads 免认证同源直出,防上传内容被当 HTML/JS 执行 + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # CSP 先 Report-Only 观察(只上报不拦截) + add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; + root /data/pms_dev; index index.html; - location /pms/api/ { - proxy_pass http://127.0.0.1:3000/; + # 登录接口限流(防暴力破解第二道;第一道在 api-server 内按手机号+IP 计数锁定) + location = /api/pms/auth/login { + limit_req zone=auth_login_zone burst=10 nodelay; + proxy_pass http://127.0.0.1:3000/api/auth/login; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + # 验证码接口限流(免认证且逐像素渲染,是 CPU 放大攻击面) + location = /api/pms/auth/challenge { + limit_req zone=auth_challenge_zone burst=10 nodelay; + proxy_pass http://127.0.0.1:3000/api/auth/challenge; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/pms/ { + proxy_pass http://127.0.0.1:3000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 10m; + } + + # 上传文件(与生产一致直出;此前测试站缺此 location,/uploads/ 会落到 root 变 404) + location /uploads/ { + limit_req zone=uploads_zone burst=60 nodelay; + proxy_pass http://127.0.0.1:3000/uploads/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + expires 30d; + } + location /pms/ { + alias /data/pms_dev/web/; try_files $uri $uri/ /pms/index.html; } + + location /h5/ { + alias /data/pms_dev/h5/; + try_files $uri $uri/ /h5/index.html; + } } diff --git a/deploy/pms-api-prod.service b/deploy/pms-api-prod.service index 03ce55f..87b3d97 100644 --- a/deploy/pms-api-prod.service +++ b/deploy/pms-api-prod.service @@ -4,7 +4,10 @@ After=network.target [Service] Type=simple -User=root +# 降权运行(2026-09 安全加固):仅需读 .env(systemd 以 root 读取 EnvironmentFile)、 +# 可写 ./uploads 与 ./exports(两者已 chown pms:pms),9000 端口 >1024 无需特权 +User=pms +Group=pms WorkingDirectory=/data/pms/api-server ExecStart=/data/pms/api-server/pms-api EnvironmentFile=/data/pms/api-server/.env diff --git a/deploy/pms-api-test.service b/deploy/pms-api-test.service index 45de2f7..b58343f 100644 --- a/deploy/pms-api-test.service +++ b/deploy/pms-api-test.service @@ -4,7 +4,10 @@ After=network.target [Service] Type=simple -User=root +# 降权运行(2026-09 安全加固):仅需读 .env(systemd 以 root 读取 EnvironmentFile)、 +# 可写 ./uploads 与 ./exports(两者已 chown pms:pms),3000 端口 >1024 无需特权 +User=pms +Group=pms WorkingDirectory=/data/pms_dev/api-server ExecStart=/data/pms_dev/api-server/pms-api EnvironmentFile=/data/pms_dev/api-server/.env diff --git a/deploy/pms-device-prod.service b/deploy/pms-device-prod.service index db9eaf6..9d4b7ef 100644 --- a/deploy/pms-device-prod.service +++ b/deploy/pms-device-prod.service @@ -4,7 +4,9 @@ After=network.target [Service] Type=simple -User=root +# 降权运行(2026-09 安全加固):9001 端口 >1024 无需特权,device-server 不写业务文件 +User=pms +Group=pms WorkingDirectory=/data/pms/device-server ExecStart=/data/pms/device-server/pms-device-server EnvironmentFile=/data/pms/device-server/.env diff --git a/deploy/pms-device-test.service b/deploy/pms-device-test.service index aaae25d..fa69be7 100644 --- a/deploy/pms-device-test.service +++ b/deploy/pms-device-test.service @@ -4,7 +4,9 @@ After=network.target [Service] Type=simple -User=root +# 降权运行(2026-09 安全加固):3001 端口 >1024 无需特权,device-server 不写业务文件 +User=pms +Group=pms WorkingDirectory=/data/pms_dev/device-server ExecStart=/data/pms_dev/device-server/device-server EnvironmentFile=/data/pms_dev/device-server/.env diff --git a/deploy_output.txt b/deploy_output.txt new file mode 100644 index 0000000..97005ab --- /dev/null +++ b/deploy_output.txt @@ -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 diff --git a/deploy_remote.sh b/deploy_remote.sh new file mode 100644 index 0000000..6cefd93 --- /dev/null +++ b/deploy_remote.sh @@ -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/ diff --git a/docs/API.md b/docs/API.md index 83bf369..9b260f3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,9 +12,9 @@ ## 统一端点清单 > **唯一事实来源**:`software/api-server/src/routes/mod.rs` 的 `.route(...)` 注册清单。 -> 本表**一行 = 一次 `.route(...)` 注册**,共 **79** 行,与 +> 本表**一行 = 一次 `.route(...)` 注册**,共 **80** 行,与 > `Select-String -Path software\api-server\src\routes\mod.rs -Pattern '\.route\(' | Measure-Object` -> 得到的 **79** 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如 `GET / POST`), +> 得到的 **80** 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如 `GET / POST`), > 权限列按方法分别标注。 > > 权限码**一律取自 `db/migrate.rs::seed_permissions` 的种子表**(共 31 个权限码)。 @@ -87,6 +87,7 @@ | PUT | `/api/users/{id}/status` | 设置用户状态 | `user:edit` | | GET | `/api/operation-logs` | 操作日志 | **`operation_log:view`** | | GET | `/api/dashboard` | 小程序首页仪表盘 | 仅需登录(内部按角色做组织隔离) | +| GET | `/api/my-organizations` | 小程序「选择组织」:当前用户**可见**的组织列表(工厂全量 / 客户仅本组织) | 仅需登录(不校验 `org:view`,内部按 `role_level` + `users.organization_id` 隔离) | | GET | `/api/cabinets` | 设备列表(含实时 RSSI) | `device:view` | | GET | `/api/compartments/{id}` | 仓体/通道详情(实时 BMS + 充电记录) | `device:view` | | POST | `/api/charge/start` | 开始充电 | `charge:start` | @@ -358,6 +359,81 @@ ## 管理端接口 +### 用户数据范围(`user_scopes`)— PUT / GET `/api/users/{id}/scopes` + +**权限**:`PUT` 需 `user:edit`;`GET` 需 `user:view`(总管理员 `role_level >= 2` 因 `check_permission` 短路而全通过)。 +**语义权威定义**见 `AGENTS.md`「组织架构 → 数据范围(user_scopes)」。 + +**范围语义(四条硬规则,不得擅改):** + +| 情形 | 行为 | +|---|---| +| 未配置任何记录 | **不限制**(沿用组织隔离,与未引入该机制时完全一致)—— 绝不是"无权限" | +| 已配置(≥1 条有效) | 在组织隔离基础上**再收窄**(交集),绝不放大可见范围 | +| 总管理员(`role_level >= 2`) | **豁免**:始终可见全量,配置保存但不生效 | +| 有记录但无一条有效(脏数据/设备级/跨组织/项目已删) | 保守忽略无效行;若一条有效都不剩 ⇒ 按"未配置 = 不限制"处理并记 `WARN` | + +**粒度到项目级**:`scope_type` `1`=组织(只允许配成本人所属组织,展开为该组织全部项目)、 +`2`=项目(**实际生效粒度**)、`3`=设备/柜子(**不参与鉴权**,读取时忽略、不生成任何过滤条件)。 + +**PUT 请求体**: + +```json +{ + "scopes": [ + { "scope_type": 2, "scope_id": 12, "permission_type": 1 } + ] +} +``` + +| 字段 | 说明 | +|------|------| +| `scope_type` | `1` 组织 / `2` 项目 / `3` 设备(设备级存档但不参与鉴权) | +| `scope_id` | 对应资源 ID,**必须存在且属于该用户所在组织**,否则 `400` | +| `permission_type` | `1` 查看 / `2` 操作(当前两者都只定义"可见边界",不区分读/写) | + +- 传 `"scopes": []` = **清空范围** = 恢复"不限制"; +- 全量替换,在一个事务内执行(校验全部通过前不删旧数据); +- 校验失败示例:`400` `{"code":400,"message":"项目 99 不属于该用户所属组织,无法配置为其数据范围"}`; + 目标用户不存在 → `404`;`scope_type`/`permission_type` 越界 → `400`。 + +**GET 响应**(返回库里**存量**记录,另附 `valid` 标记该行是否会被鉴权消费): + +```json +{ + "code": 0, + "data": [ + { "id": 3, "scope_type": 2, "scope_id": 12, "permission_type": 1, "valid": true }, + { "id": 4, "scope_type": 3, "scope_id": 7, "permission_type": 1, "valid": true } + ], + "message": "ok" +} +``` + +> `valid` 的含义是「**这条记录会不会被鉴权消费**」: +> - `false` = 不会生效 —— 资源已删除 / 跨组织 / 无效类型,**或 `scope_type=3`(设备级,永远不参与鉴权)**; +> - 鉴权时这些行被忽略;管理端可据此提示管理员清理。 +> `GET /api/users/{id}/permissions` 的 `scopes` 字段仍为原始数组(不带 `valid`)。 + +**受数据范围影响的接口**(组织条件之后再叠加项目级收窄;未配置时不产生任何额外条件): + +| 接口 | 收窄落点 | +|------|----------| +| `GET /api/cabinets/filter`、`GET /api/projects/{id}/cabinets`、`GET /api/cabinets/options` | `cabinets.project_id` | +| `GET /api/cabinets/{id}`、`GET /api/cabinets/realtime-status` | 归属校验(不在范围内 → `403`) | +| `GET /api/charge-records`、`GET /api/charge-records/export` | `charge_records.cabinet_id` → 项目 | +| `GET /api/charge-orders` | `charge_orders.cabinet_id` → 项目 | +| `GET /api/device-logs` | `device_logs.cabinet_id` → 项目 | +| `GET /api/energy-stats/{summary,cabinets,projects,trend,export}` | 项目/柜子维度 | +| `GET /api/organizations/{org_id}/projects` | `projects.id` | +| 所有柜子写操作(`PUT/DELETE /api/cabinets/{id}`、`work-mode`、`status`、`bind`、`replace-imei`、`regenerate-auth`、`/api/devices/{dev_id}/command`、`/api/projects/{id}`) | `scope_guard`(不在范围内 → `403`) | +| 小程序:`GET /api/cabinets`、`GET /api/dashboard`、`GET /api/compartments/{id}`、`POST /api/charge/*`、`POST /api/door/open`、`/api/cabinets/{id}/{open,pow-on,pow-off,reset}` | 设备列表按项目过滤;详情/写操作归属校验 → `403` | + +> `GET /api/operation-logs` **不叠加**数据范围:操作日志按 `user_id → users.organization_id` 归属, +> 与项目/设备无关联列,项目级范围无法映射(经确认,非遗漏)。 + +--- + ### GET /api/charge-records 充电记录列表,支持按柜子/时间过滤,响应含组织数据隔离。 @@ -667,255 +743,7 @@ Nginx 有独立的 `/api/wechat/` location 直连 API 服务,不经 `/api/pms/ ## TCP 设备通讯协议 -> ⛔ **本节(自本行起至下一个 `##` 标题之前)全部内容已废弃,禁止作为实现依据。** -> **唯一权威文档:[`柜控板固件对接文档.md`](./柜控板固件对接文档.md)(v1.1)** —— 所有 TCP 报文格式、字段名、 -> `act` 清单、签名算法、保活/超时规则、时序与异常处理,**一律以该文档为准**。 -> -> 本节保留仅为历史参考。已知与定稿规范的**重大冲突**(仅列关键几处,避免误读): -> - ~~`dev_id` 上行全部报文必带~~ → **只有 `auth_str` / `login` 带**,其余报文不带(设备身份由 TCP 连接确定) -> - ~~`id` = `IMEI` / `IMEI-仓控板-通道`~~ → **`id` 不含 IMEI**:柜级**省略**、单板一段 `"01"`、仓体级两段 `"01-3"` -> - ~~空闲 1 分钟 / 充电 15 秒上报~~ → **`status_post` 固定每 60 秒一次、每次全量** -> - ~~`login` 应答返回 `cell`~~ → 返回 **`spec`**(柜子规格,如 `"0606"`)与 **`work_mode`** -> - ~~`pow_fail` 带 `info`~~ → 改为 **`pow_fail_dc` / `pow_fail_ac` 两条独立指令**,无附加字段 -> - ~~`status_post` 的 `status` HEX 字典 / `bat_in` 的 `bms` 块~~ → 改为 **`cabinet` + `boards[].channels[]`(每通道 18 字符 hex 透传)** 与 **`battery` 块** -> - ~~`bat_in` 建单~~ → **`bat_in` 与订单无关,订单只由 `on` 建立** -> - 内部字段已由 `id` 改为 **`_dev_id`**(供 device-server 路由,下发前摘除) - -### 设计原则 - -> MCU(Air780E)资源有限,TCP 协议遵循以下原则: -> 1. **响应扁平化**:字段直接放在顶层,不包 `data`/`result` 等嵌套层 -> 2. **字段最小化**:需要几个字段传几个,不传空值、默认值 -> 3. **JSON 紧凑**:去空格回车,不传多余引号和逗号(可用 `serde_json::to_string` 紧凑模式) -> 4. **响应必达**:所有**上行**报文平台都会回复;但**设备回执(带 `suc`)平台不再回包**(防误配对) - -### 通信规则(⚠️ 下列旧描述已废弃,见节首说明) - -- TCP 长连接,无需 PING 包 ✅(保活由 `status_post` 承担) -- ~~空闲 1 分钟上报,充电 15 秒上报~~ ❌ → **固定每 60 秒全量** -- ~~超时 5000ms~~ ❌ → **每个交互 60 秒超时,超时作废、迟到应答丢弃、不重试** -- JSON 去空格回车省流量,LF 处理粘包 ✅ -- ~~`dev_id`:设备上报时使用~~ ❌ → **仅 `auth_str` / `login` 携带** -- ~~`id`:`IMEI-仓控板-通道` / `IMEI`~~ ❌ → **不含 IMEI**:柜级省略 / 单板 `"01"` / 仓体级 `"01-3"` -- `msg_id`:两个方向**各自独立计数,都每连接从 `1` 起自增**,重连归零;判"应答 vs 指令"看有无 `suc` - -### 签名机制 - -- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次) -- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)` -- 时间戳秒级,平台校验±60秒 - -### 指令清单 - -> ⛔ **以下整段(含本表与其后的报文示例、`cell` / `pow_fail` / `bms` 字段说明)均为废弃的历史内容,请勿据此实现。** -> 现行清单与报文以 [`柜控板固件对接文档.md`](./柜控板固件对接文档.md) §4 / §5 为准。**`act` 共 13 个**: -> `auth_str` / `login` / `status_post` / `pow_fail_dc` / `pow_fail_ac` / `bat_in` / `bat_out` / `on` / `off` / `open` / `pow_on` / `pow_off` / `reset` -> (**已无 `pow_fail`**,拆成 `pow_fail_dc` / `pow_fail_ac`) - -| 动作 | 方向 | 说明(⚠️ 历史,见上方说明) | -|------|------|------| -| `login` | 设备→平台 | 登录(含 `sign`+`timestamp`+`iccid`);成功应答返回 **`spec`** + **`work_mode`**(不再是 `cell`) | -| `auth_str` | 设备→平台 | 获取安全码(首次 / 重置后;失败统一回 `invalid signature` 之外的两条见权威文档) | -| `status_post` | 设备→平台 | **每 60 秒全量**;结构为 `cabinet` + `boards[].channels[]`(每通道 18 字符 hex),**不再是 `status` 字典** | -| ~~`pow_fail`~~ | 设备→平台 | **已废弃** → 改为 `pow_fail_dc` / `pow_fail_ac`(无附加字段) | -| `pow_fail_dc` / `pow_fail_ac` | 设备→平台 | 直流停电 / 交流停电(事件上报) | -| `bat_in` | 设备→平台 | 电池插入(`battery` 块:属性 + `info` 18hex + `cells` hex);**不建单** | -| `bat_out` | 设备→平台 | 电池拔出(结束订单 `user_stop`) | -| `on` | **双向** | 开始充电(上行两形态:带 `suc`=回执 / 不带=上报;**建单唯一入口**) | -| `off` | **双向** | 充电停止(结束订单 `normal`) | -| `open` | 平台→设备 | **柜门解锁**(柜级:整柜共用一扇门,需人拉开;无"关门"指令) | -| `pow_on` / `pow_off` | 平台→设备 | 接触器合闸 / 分闸(`pow_off` 后仓控板随之下电 → 上报 `pow_fail_dc`) | -| `reset` | 平台→设备 | 重启整柜(`id` 省略)或单块仓控板(`id="01"`);**先回执再重启** | - -> ⚠️ 所有设备上报消息平台都会回复 `{"suc":1,"msg_id":N}` 确认。响应字段扁平化,不加 `data` 包裹。 - -### 全局单位规范 - -所有数值字段使用整数存储,禁止浮点类型: - -| 字段 | 单位 | 精度 | 说明 | -|------|------|------|------| -| `voltage` | mV | 1 | 52000 = 52.000V | -| `current` | mA | 1 | 10000 = 10.000A | -| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C | -| `soc` | ‰ | 0.1% | 4500 = 45.00% | - -### 服务架构 - -``` -设备 ─TCP─→ device-server ─LPUSH→ Redis ─BRPOP→ api-server -设备 ←TCP─ device-server ←BRPOP─ Redis ←LPUSH─ api-server -``` - -**职责分层:** - -| 层 | 职责 | -|---|------| -| **device-server** | TCP连接管理、协议解析(HEX→结构化)、消息转发 | -| **api-server** | 业务逻辑:签名验证、状态持久化、充电事件、告警、指令下发 | - -**Redis 队列:** - -| 队列 | 方向 | 说明 | -|------|------|------| -| `device:reports` | 设备→平台 | device-server 转发设备上报 | -| `device:commands` | 平台→设备 | api-server 下发指令/响应 | - -### 全局单位规范 - -#### login(设备→平台) - -```json -{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"iccid":"89860000000000000000"} -// 响应:{"suc":1,"msg_id":1,"cell":6} -``` - -- `iccid` — SIM卡ICCID(变更时写 SIM 变更日志) -- `cell` — 仓控板数量(柜子配置属性) - ---- - -#### auth_str(设备→平台) - -```json -{"act":"auth_str","msg_id":2,"dev_id":"123456789012345"} -// 响应:{"suc":1,"msg_id":2,"auth_str":"abcd1234"} -``` - -- `auth_str` — 8位随机安全码(仅返回一次,标记后不可重复获取) - ---- - -#### status_post(设备→平台) - -```json -{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85, - "status":{"00":"0001","01":"000100020003000400050006","02":"000100020003000400050006"}} -// 响应:{"suc":1,"msg_id":10} -``` - -**status 字段:** -- key: `"00"`=柜子级, `"01"`~`"N"`=仓控板级(N = cell-1) -- value: 6通道状态拼接(每通道4个HEX字符=2字节) -- device-server 解析后附加 `parsed_status` 字段,api-server 直接写入 Redis - -**HEX 解析(device-server 负责,格式待硬件团队最终确认):** -- 字节1 bit1=充电中, bit2=充满, bit3=故障 -- 当前实现:每通道4字符,取字节1解析 - -> ⚠️ HEX 编码格式待硬件团队最终确认,当前为预留实现。 - ---- - -#### pow_fail(设备→平台) - -```json -{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"} -// 响应:{"suc":1,"msg_id":15} -``` - -- `info` — 停电类型:`0`=直流停电(12V掉电),`1`=交流停电(380V掉电) - ---- - -#### bat_in / bat_out(设备→平台) - -```json -{"act":"bat_in","msg_id":20,"id":"123456789012345-01-01","bms":{ - "soc":4500,"voltage":52000,"current":10000,"temp":29865, - "battery_id":"BAT001","brand":"星恒","model":"XH-48V","sn":"SN123456" -}} -// 响应:{"suc":1,"msg_id":20} - -{"act":"bat_out","msg_id":21,"id":"123456789012345-01-01","bms":{ - "battery_id":"BAT001","soc":4500 -}} -// 响应:{"suc":1,"msg_id":21} -``` - -**BMS 字段(全局整数单位):** - -| 字段 | 单位 | 精度 | 说明 | -|------|------|------|------| -| `soc` | ‰ | 0.1% | 4500 = 45.00% | -| `voltage` | mV | 1 | 52000 = 52.000V | -| `current` | mA | 1 | 10000 = 10.000A | -| `temp` | K×100 | 0.01K | 29865 = 298.65K = 25.5°C | -| `battery_id` | — | — | 电池唯一标识 | -| `brand` | — | — | BMS品牌 | -| `model` | — | — | BMS型号 | -| `sn` | — | — | BMS序列号 | -| `cell_voltages` | mV | 1 | 单体电芯电压数组 | - -> `bat_out` 只需上传 `battery_id`、`soc`(其他数据不变)。 - ---- - -#### on / off(双向) - -```json -// 平台→设备:下发充电 -{"act":"on","msg_id":30,"id":"123456789012345-01-01"} -// 设备响应:{"suc":1,"msg_id":30} - -// 设备→平台:充电开始确认 -{"act":"on","msg_id":30,"id":"123456789012345-01-01"} -// 平台响应:{"suc":1,"msg_id":30} - -// 平台→设备:下发停止 -{"act":"off","msg_id":31,"id":"123456789012345-01-01"} -// 设备响应:{"suc":1,"msg_id":31} - -// 设备→平台:充满自动停止 -{"act":"off","msg_id":31,"id":"123456789012345-01-01"} -// 平台响应:{"suc":1,"msg_id":31} -``` - -> `on` 必须由平台下发,设备不可主动充电。MCU 保证 `off` → `bat_out` 顺序。 - ---- - -#### open(平台→设备) - -```json -{"act":"open","msg_id":40,"id":"123456789012345"} -``` - -**响应:** `{"suc":1,"msg_id":40}` - ---- - -#### pow_on / pow_off(平台→设备) - -```json -{"act":"pow_on","msg_id":50,"id":"123456789012345"} -{"act":"pow_off","msg_id":51,"id":"123456789012345"} -``` - -**响应:** `{"suc":1,"msg_id":50}` - ---- - -#### reset(平台→设备) - -```json -// 重启整柜 -{"act":"reset","msg_id":60,"id":"123456789012345"} -// 重启仓控板 -{"act":"reset","msg_id":61,"id":"123456789012345-01"} -``` - -**响应:** `{"suc":1,"msg_id":60}` - -### 充电结束原因 - -| 平台收到 | 判断原因 | -|---------|---------| -| `off` (设备→平台) | `normal` — 正常充满 | -| `off` (平台→设备) | `user_stop` — 用户手动停止 | -| `bat_out` | `normal` — 电池拔出 | -| `bat_in` (通道已有电池) | `forced` — 新电池插入强制结束 | -| 超时未响应 | `timeout` — 通信超时 | +> TCP 设备通讯协议见 **[柜控板固件对接文档.md](柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。 --- diff --git a/software/api-server/src/captcha.rs b/software/api-server/src/captcha.rs index 8075c05..37c16a1 100644 --- a/software/api-server/src/captcha.rs +++ b/software/api-server/src/captcha.rs @@ -10,6 +10,9 @@ use base64::Engine; use image::{Rgba, RgbaImage}; use rand::Rng; use std::io::Cursor; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::sync::{Semaphore, SemaphorePermit}; use crate::error::AppError; @@ -17,11 +20,45 @@ const WIDTH: u32 = 300; const HEIGHT: u32 = 150; const PIECE_SIZE: i32 = 44; const BUMP_RADIUS: i32 = 7; -const TOLERANCE: i32 = 5; +/// 滑块偏移容差(像素) +/// +/// 前端按 300×150 原始尺寸 1:1 渲染(不缩放),拖动量为整数像素, +/// 因此 3px 足以覆盖触屏取整/手抖误差,同时把盲猜命中率从原来的 +/// 11/156 ≈ 7% 降到 7/156 ≈ 4.5%(可选位置 156 个)。 +const TOLERANCE: i32 = 3; const PIECE_WIDTH: i32 = PIECE_SIZE + BUMP_RADIUS; const CHALLENGE_TTL: u32 = 300; const SHADOW_OFFSET: i32 = 3; +/// 同时渲染的验证码上限(服务器 2 核,避免免认证请求把 CPU 打满) +const MAX_CONCURRENT_RENDER: usize = 2; +/// 等待渲染闸门的最长时间,超时返回 429(有界排队,防止请求堆积) +const RENDER_WAIT_TIMEOUT: Duration = Duration::from_secs(3); + +/// 渲染并发闸门(进程级,懒加载) +fn render_gate() -> &'static Semaphore { + static GATE: OnceLock = OnceLock::new(); + GATE.get_or_init(|| Semaphore::new(MAX_CONCURRENT_RENDER)) +} + +/// 获取渲染许可(有界等待):排队超时 → 429,闸门关闭 → 500 +async fn acquire_render_permit() -> Result, AppError> { + match tokio::time::timeout(RENDER_WAIT_TIMEOUT, render_gate().acquire()).await { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(_)) => { + tracing::error!("[captcha] 渲染闸门已关闭"); + Err(AppError::Internal("服务临时不可用".into())) + } + Err(_) => { + tracing::warn!( + max = MAX_CONCURRENT_RENDER, + "challenge 渲染排队超时,返回 429" + ); + Err(AppError::TooManyRequests("服务繁忙,请稍后再试".into())) + } + } +} + pub(crate) struct CaptchaData { pub(crate) challenge_id: String, pub(crate) bg_image: String, @@ -32,6 +69,9 @@ pub(crate) async fn create_challenge(redis_url: &str) -> Result Result Result<(), sqlx::Error> { ) .await?; + // 更新 compartments.status 注释(**旧的注释是错的**:老库写的是 + // `0空闲 1充电中 2故障 3禁用`,但全仓没有任何地方会写 2/3 —— 平台只写 0/1, + // `routes/workers/report_worker.rs::mark_compartment_status` 的调用点只有 0 与 1; + // 而"禁用"是设备/组织级状态,仓体级并不存在)。 + // 本列语义从此**只表达占用**:"充满 / 故障"由 Redis 通道实时状态派生 + // (`routes/cabinet_tree.rs::derive_channel_flags`),不落库。 + // marker 取注释里的独有片段:老注释不含它 → 只 ALTER 一次,之后启动不再重复 DDL。 + ensure_column_comment( + pool, + "compartments", + "status", + "充满/故障由通道实时状态派生", + r#"ALTER TABLE compartments MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0空闲 1充电中(仅表示占用);充满/故障由通道实时状态派生,不写入本列'"#, + ) + .await?; + // 创建关键索引(IF NOT EXISTS 避免重复) create_indexes(pool).await?; + // 能耗报表聚合所需的 charge_records 索引:**纯性能优化**, + // 失败只记 ERROR、绝不阻断启动(理由见函数文档)。 + if let Err(e) = ensure_energy_report_indexes(pool).await { + tracing::error!( + "[migrate] charge_records 能耗报表索引创建失败(服务继续启动;报表查询会退化为全表扫描)err={}", + e + ); + } + // 兼容已有数据库:新增字段(幂等,列已存在则跳过) add_columns_if_not_exists(pool).await?; + // 历史库「列在、但类型/可空性/默认值错了」的漂移对齐 + organizations.parent_id 索引/外键。 + // + // ⚠️ 必须放在 `add_columns_if_not_exists` **之后**:它要建索引的 + // `organizations.parent_id` 可能刚由上一步补出来(老库该列不存在)。 + // ⚠️ 失败只记 ERROR:`run()` 返回 Err 会让 main.rs 的 `.expect("数据库迁移失败")` + // panic 成崩溃循环(systemd 反复重启),列类型对齐不配享有这种杀伤力。 + if let Err(e) = align_legacy_columns(pool).await { + tracing::error!( + "[migrate] 历史列定义对齐失败(服务继续启动;老库与生产的结构漂移可能仍在)err={}", + e + ); + } + // P0-2 的 DB 层兜底:**同一仓体最多一笔活跃订单**(`status = 0`)的唯一约束。 // // ⚠️ 这一步的失败**绝不能中断启动**:`run()` 返回 Err 会让 main panic 退出(生产事故), @@ -319,31 +362,38 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { Ok(()) } +/// 辅助:检查索引是否存在,不存在则创建(幂等:已存在即跳过,不重复 DDL) +/// +/// 失败上抛给调用方,由调用方决定口径: +/// - [`create_indexes`] 直接上抛 ⇒ 经 [`run`] **中断启动**(索引是可用性前提); +/// - [`align_legacy_columns`] 只记 ERROR ⇒ **绝不阻断启动**。 +/// +/// 安全性:表名/索引名/列名全部来自本文件内的白名单常量,不含任何外部输入拼接。 +async fn ensure_index( + pool: &MySqlPool, + table: &str, + index: &str, + columns: &str, +) -> Result<(), sqlx::Error> { + let exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM information_schema.statistics \ + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", + ) + .bind(table) + .bind(index) + .fetch_one(pool) + .await?; + + if exists.0 == 0 { + let ddl = format!("CREATE INDEX {} ON {}({})", index, table, columns); + sqlx::query(&ddl).execute(pool).await?; + tracing::info!("[migrate] 已创建索引 {} ON {}({})", index, table, columns); + } + Ok(()) +} + /// 创建关键查询索引(幂等,已存在则跳过) async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> { - /// 辅助:检查索引是否存在,不存在则创建 - async fn ensure_index( - pool: &MySqlPool, - table: &str, - index: &str, - columns: &str, - ) -> Result<(), sqlx::Error> { - let exists: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM information_schema.statistics \ - WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", - ) - .bind(table) - .bind(index) - .fetch_one(pool) - .await?; - - if exists.0 == 0 { - let ddl = format!("CREATE INDEX {} ON {}({})", index, table, columns); - sqlx::query(&ddl).execute(pool).await?; - } - Ok(()) - } - ensure_index(pool, "cabinets", "idx_cabinets_project", "project_id").await?; ensure_index(pool, "cabin_boards", "idx_cabin_boards_cabinet", "cabinet_id").await?; ensure_index(pool, "compartments", "idx_compartments_board", "cabin_board_id").await?; @@ -358,6 +408,31 @@ async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> { Ok(()) } +/// 能耗报表(`routes/energy_stats.rs`)所需的 `charge_records` 索引。 +/// +/// 报表已从 `energy_stats`(恒 0 行)改为**直接聚合 `charge_records`**,因此: +/// - 按柜聚合(柜排名 / 项目统计)→ `(cabinet_id, start_time)` 联合索引; +/// - 全局按时间范围聚合(总览 / 趋势 / 导出)→ `(start_time)` 索引。 +/// +/// 缺这两条索引时,每次打开能耗页/导出都要全表扫描 `charge_records`。 +/// +/// # 为什么不放在 [`create_indexes`] 里 +/// `create_indexes` 的错误会经 [`run`] 上抛(**阻断启动**)。这两条索引是**性能优化**, +/// 不是服务可用性的前提:建不出来(权限/磁盘/DDL 冲突)只该记 ERROR, +/// 绝不能因此让 API 起不来。幂等性由 [`ensure_index`] 保证 +/// (索引已存在即跳过),因此每次启动重复调用无害。 +async fn ensure_energy_report_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> { + ensure_index( + pool, + "charge_records", + "idx_charge_records_cabinet_start_time", + "cabinet_id, start_time", + ) + .await?; + ensure_index(pool, "charge_records", "idx_charge_records_start_time", "start_time").await?; + Ok(()) +} + /// DB 层兜底:**同一仓体最多只能有一笔活跃订单**(`status = 0`)的唯一约束。 /// /// # 为什么需要(P0-2) @@ -529,13 +604,16 @@ async fn ensure_column_nullable( /// /// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新; /// 而 MODIFY 会重建整表,因此必须先比对注释,避免每次启动都白跑一次 DDL。 +/// +/// 返回 `Ok(true)` 表示本次真的执行了 DDL(供 [`align_legacy_columns`] 统计), +/// `Ok(false)` = 注释已含 marker 或列不存在(no-op)。 async fn ensure_column_comment( pool: &MySqlPool, table: &str, column: &str, marker: &str, ddl: &str, -) -> Result<(), sqlx::Error> { +) -> Result { // ⚠️ `information_schema.COLUMNS.COLUMN_COMMENT` 在 MySQL 8 上会被 sqlx 报告为 // **VARBINARY**,直接按 `Option` 解码会 panic(实测阿里云 RDS MySQL 8.0.36): // `mismatched types; Rust type String (as SQL type VARCHAR) is not compatible with SQL type VARBINARY` @@ -552,10 +630,18 @@ async fn ensure_column_comment( if let Some(current) = comment { if !current.contains(marker) { sqlx::query(ddl).execute(pool).await?; - tracing::info!("[migrate] 更新列注释 {}.{}", table, column); + tracing::info!( + "[migrate] 更新列注释 {}.{}(原注释 '{}' 不含 '{}');DDL={}", + table, + column, + current, + marker, + ddl + ); + return Ok(true); } } - Ok(()) + Ok(false) } /// 逐列补齐历史版本遗漏的列(幂等,兼容线上已存在的旧表) @@ -629,6 +715,431 @@ async fn add_columns_if_not_exists(pool: &MySqlPool) -> Result<(), sqlx::Error> Ok(()) } +/// 单列对齐规格:由 [`LEGACY_COLUMN_TARGETS`] 的「目标定义原文」解析而来 +/// +/// 判定字段与 DDL 原文**同源**(都从同一段目标定义推出),因此不存在 +/// 「判定说一致、DDL 却写成别的」这种会退化成"每次启动都 DDL"的错位。 +struct ColumnSpec { + /// 表名(本文件内的白名单常量,不含任何外部输入) + table: &'static str, + /// 列名(同上) + column: &'static str, + /// `information_schema.COLUMNS.DATA_TYPE` 的目标值(大小写不敏感比较) + data_type: &'static str, + /// 目标可空性(`true` = 允许 NULL,对应 `IS_NULLABLE = 'YES'`) + nullable: bool, + /// `information_schema.COLUMNS.COLUMN_DEFAULT` 的目标值。 + /// MySQL 8 对 `DEFAULT CURRENT_TIMESTAMP` 返回 `CURRENT_TIMESTAMP`(大写), + /// 对数值默认值返回 `0`;**无默认值为空串**(`COLUMN_DEFAULT` 为 NULL)。 + default_value: &'static str, + /// 目标是否要求 `EXTRA` 里带 `on update CURRENT_TIMESTAMP` + on_update: bool, + /// 目标注释必须包含的片段(空串 = 不校验注释) + comment_marker: &'static str, + /// 判定不一致时执行的 ALTER 原文 + ddl: String, +} + +/// 取目标定义原文的第一个词(SQL 类型名),如 `DATETIME` / `BIGINT` +fn target_data_type(target: &str) -> &str { + target.split_whitespace().next().unwrap_or_default() +} + +/// 取目标定义原文里 `DEFAULT ` 的值(无 `DEFAULT` 子句返回空串) +fn target_default_value(target: &str) -> &str { + match target.find("DEFAULT ") { + Some(pos) => target[pos + "DEFAULT ".len()..] + .split_whitespace() + .next() + .unwrap_or_default(), + None => "", + } +} + +/// 取目标定义原文里 `COMMENT '<文本>'` 的文本(无 `COMMENT` 子句返回空串) +fn target_comment(target: &str) -> &str { + match target.find("COMMENT '") { + Some(pos) => { + let rest = &target[pos + "COMMENT '".len()..]; + match rest.find('\'') { + Some(end) => &rest[..end], + None => "", + } + } + None => "", + } +} + +impl ColumnSpec { + /// 由「表名 + 列名 + 目标定义原文」构造规格 + fn from_target(table: &'static str, column: &'static str, target: &'static str) -> Self { + Self { + table, + column, + data_type: target_data_type(target), + nullable: !target.contains("NOT NULL"), + default_value: target_default_value(target), + on_update: target.contains("ON UPDATE CURRENT_TIMESTAMP"), + comment_marker: target_comment(target), + ddl: format!("ALTER TABLE {} MODIFY COLUMN {} {}", table, column, target), + } + } +} + +/// **列定义对齐清单**:`(表名, 列名, 目标定义)` —— 目标定义 = `SCHEMA_SQL` 里该列的 DDL 原文 +/// +/// # 为什么要有这份清单 +/// 两件事叠加,让「列在、但类型/默认值/单位错了」的漂移**永远不会被自动修复**: +/// 1. `CREATE TABLE IF NOT EXISTS` 只保证「表存在」,对**已存在**的表不改任何列定义; +/// 2. `add_columns_if_not_exists` 的白名单只补「**缺失的列**」,不看已有列的定义。 +/// +/// # 为什么时间列一律写成 `DATETIME`(本清单存在的核心动因) +/// sqlx 的 `chrono::NaiveDateTime` 只与 SQL 类型 **`DATETIME`** 兼容 +/// (`sqlx-mysql-0.8.6/src/types/chrono.rs`:`impl Type for NaiveDateTime` 没有覆写 +/// `compatible`,于是走默认实现 —— 要求类型名**逐字相等**),而 `TIMESTAMP` 只与 +/// `DateTime` 兼容。本项目时间列在 Rust 侧全是 `NaiveDateTime` / +/// `Option`(`charge_records.rs`、`charge_orders.rs`、`device_logs.rs`、 +/// `users.rs`、`downloads.rs`、`operation_logs.rs`、`order_timeout.rs` …), +/// 因此**任何一处仍为 `TIMESTAMP` 的时间列都会让对应接口直接 500**: +/// ```text +/// error occurred while decoding column "start_time": mismatched types; +/// Rust type `Option` (as SQL type `DATETIME`) +/// is not compatible with SQL type `TIMESTAMP` +/// ``` +/// 已实测的两处后果:测试环境充电记录导出必败(`pms_dev.charge_records.start_time` 曾是 +/// `TIMESTAMP`)、生产 `/api/charge-orders` 一有订单就 500(`pms.charge_orders.start_time` +/// 仍是 `TIMESTAMP`)。 +/// +/// # 目标定义的写法 +/// 刻意写成**显式 `NOT NULL` + 显式 `DEFAULT`**:`TIMESTAMP` 在 +/// `explicit_defaults_for_timestamp=OFF` 下会隐式变成 `NOT NULL DEFAULT CURRENT_TIMESTAMP`, +/// 而 `DATETIME` 不会 —— 不显式写明就会在换库/换配置时悄悄改变可空性。 +/// +/// # 幂等 +/// [`ensure_column_type`] 先读 `information_schema.COLUMNS` 的 +/// `DATA_TYPE`/`IS_NULLABLE`/`COLUMN_DEFAULT`/`EXTRA`/`COLUMN_COMMENT`, +/// **只有与目标不一致才 ALTER**;一致时一条 DDL 都不发(避免每次启动都重建表)。 +const LEGACY_COLUMN_TARGETS: &[(&str, &str, &str)] = &[ + // ── 时间列:一律 DATETIME(TIMESTAMP → DATETIME)────────────────────────── + // `created_at` 家族:NOT NULL + DEFAULT CURRENT_TIMESTAMP + ("organizations", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("projects", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("cabinets", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("cabin_boards", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("compartments", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("batteries", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("charge_orders", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("users", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("charge_records", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("device_logs", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("energy_stats", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("operation_logs", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("roles", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("permissions", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("user_scopes", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("download_tasks", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("abstract_bindings", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ("user_feedback", "created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"), + // `updated_at` 家族:多一条 ON UPDATE(SCHEMA_SQL 原文如此,缺了它 `updated_at` 不会自动刷新) + ("organizations", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + ("projects", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + ("cabinets", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + ("cabin_boards", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + ("compartments", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + ("batteries", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + ("users", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + ("roles", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + // 充电记录/订单的业务时间列(两库都曾是 TIMESTAMP,测试环境导出与生产订单接口的直接原因) + ( + "charge_records", + "start_time", + "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '充电开始时间'", + ), + ( + "charge_records", + "end_time", + "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '充电结束时间'", + ), + // 订单时间列**保持可空**(订单在充电中时 end_time 尚不存在,不能 NOT NULL) + ("charge_orders", "start_time", "DATETIME NULL COMMENT '充电开始时间'"), + ("charge_orders", "end_time", "DATETIME NULL COMMENT '充电结束时间'"), + ("download_tasks", "completed_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '完成时间'"), + ("sim_change_logs", "changed_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间'"), + // ── 数值列:类型 + 单位口径注释 ────────────────────────────────────────── + // 电量单位是 **Wh**(代码写入的也是 Wh),老库是 DECIMAL(10,2) 且注释写成 kWh: + // - `DECIMAL` 无法解码进 `Option`(`ChargeRecordRow.energy`),导出同样必败; + // - 注释单位写错会直接误导报表口径。 + ( + "charge_records", + "energy", + "BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)'", + ), + ( + "energy_stats", + "total_energy", + "BIGINT NOT NULL DEFAULT 0 COMMENT '总能耗(Wh)'", + ), + // ── 组织树父列:类型/可空性/默认值两库本就一致,这里只把注释口径统一到代码 DDL ── + ( + "organizations", + "parent_id", + "BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)'", + ), +]; + +/// **纯注释口径对齐清单**:`(表名, 列名, 注释里必须出现的片段, 需要时执行的 ALTER 原文)` +/// +/// # 为什么与 [`LEGACY_COLUMN_TARGETS`] 分开列 +/// 1. 这 3 列**只有注释措辞不同**(类型/可空性/默认值两库本来就一致),走 +/// [`ensure_column_comment`] 这条既有范式即可,不必伪装成"列定义对齐"; +/// 2. `cabinets.address` **不在 `SCHEMA_SQL` 的建表语句里**(它是 +/// `add_columns_if_not_exists` 给老库补的列),塞进主清单会让 +/// `test_every_alignment_target_appears_verbatim_in_schema_sql` 失效或被迫失真。 +/// +/// # 为什么连注释也要对齐 +/// 注释就是列的文档,而这个项目已经因为「注释与实现不一致」反复踩坑: +/// `charge_records.energy` 注释写 `kWh`(实为 Wh)、`compartments.status` 注释写 +/// 「2故障 3禁用」(实为「仅表示占用,充满/故障由实时状态派生」)。 +/// 这 3 处零风险(涉及两张表:batteries 0 行、cabinets 68 行),一次性拉平到代码 DDL 的口径。 +const LEGACY_COLUMN_COMMENTS: &[(&str, &str, &str, &str)] = &[ + ( + "batteries", + "brand", + "由 brand_code 映射出的名称", + "ALTER TABLE batteries MODIFY COLUMN brand VARCHAR(50) NULL COMMENT \ + '品牌(由 brand_code 映射出的名称,代码表未定前为空)'", + ), + ( + "batteries", + "model", + "由 model_code 映射出的名称", + "ALTER TABLE batteries MODIFY COLUMN model VARCHAR(50) NULL COMMENT \ + '型号(由 model_code 映射出的名称,代码表未定前为空)'", + ), + ( + "cabinets", + "address", + "详细地址", + "ALTER TABLE cabinets MODIFY COLUMN address VARCHAR(200) NULL COMMENT '详细地址'", + ), +]; + +/// 单列对齐:先查 `information_schema.COLUMNS`,**只有与目标不一致才 ALTER**(幂等) +/// +/// 返回 `Ok(true)` 表示本次真的执行了 DDL;`Ok(false)` 表示本来就已经一致(no-op)。 +/// +/// 比对五个维度 —— 类型 / 可空性 / 默认值 / `ON UPDATE` / 注释, +/// 与 [`LEGACY_COLUMN_TARGETS`] 里的目标定义原文逐项对应: +/// - `DATA_TYPE`:`TIMESTAMP` 与 `DATETIME` 对 sqlx 是**两种不兼容的类型**(本清单的核心); +/// - `IS_NULLABLE`:`DATETIME` 不会像 `TIMESTAMP` 那样隐式 NOT NULL,必须显式比对; +/// - `COLUMN_DEFAULT`:`DATETIME` 同样不会隐式拿到 `CURRENT_TIMESTAMP` 默认值; +/// - `EXTRA`:`ON UPDATE CURRENT_TIMESTAMP` 丢了不会报错,只会让 `updated_at` **静默不再刷新**; +/// - `COLUMN_COMMENT`:单位口径(Wh/kWh)写错会误导报表,必须一起对齐。 +/// +/// # 为什么必须先查再改 +/// `MODIFY COLUMN` 在 MySQL 8 上多数情况走「重建整表」路径,大表上会长时间持有 +/// 元数据锁、阻塞并发读写。启动迁移**每次重启都跑**,无条件 MODIFY 等于每次重启 +/// 都拿生产表做一次 DDL —— 与 [`ensure_column_comment`] / [`ensure_column_nullable`] +/// 的既有口径一致:先比对、再决定。 +/// +/// # MySQL 8 的 VARBINARY 坑(踩过) +/// `information_schema.COLUMNS` 的字符列会被 sqlx 报告为 **VARBINARY**,按 `String` +/// 解码直接报 `mismatched types ... is not compatible with SQL type VARBINARY`, +/// 因此每个字符列都必须显式 `CAST(... AS CHAR)`(同 [`ensure_column_comment`])。 +async fn ensure_column_type(pool: &MySqlPool, spec: &ColumnSpec) -> Result { + let current: Option<( + Option, + Option, + Option, + Option, + Option, + )> = sqlx::query_as( + "SELECT CAST(DATA_TYPE AS CHAR), CAST(IS_NULLABLE AS CHAR), \ + CAST(COLUMN_DEFAULT AS CHAR), CAST(EXTRA AS CHAR), \ + CAST(COLUMN_COMMENT AS CHAR) \ + FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + ) + .bind(spec.table) + .bind(spec.column) + .fetch_optional(pool) + .await?; + + // 列本身不存在(None)时不动:补列是 `add_columns_if_not_exists` 的职责 + let Some((data_type, is_nullable, column_default, extra, comment)) = current else { + return Ok(false); + }; + + let data_type = data_type.unwrap_or_default(); + let is_nullable = is_nullable.unwrap_or_default(); + let column_default = column_default.unwrap_or_default(); + let extra = extra.unwrap_or_default(); + let comment = comment.unwrap_or_default(); + + let type_differs = !data_type.eq_ignore_ascii_case(spec.data_type); + let nullable_differs = (is_nullable == "YES") != spec.nullable; + let default_differs = !column_default.eq_ignore_ascii_case(spec.default_value); + let on_update_differs = + extra.to_lowercase().contains("on update current_timestamp") != spec.on_update; + let comment_differs = + !spec.comment_marker.is_empty() && !comment.contains(spec.comment_marker); + + if !(type_differs || nullable_differs || default_differs || on_update_differs || comment_differs) + { + return Ok(false); + } + + sqlx::query(&spec.ddl).execute(pool).await?; + tracing::info!( + "[migrate] 列定义对齐 {}.{}:type {} -> {}, nullable {} -> {}, default '{}' -> '{}', \ + on_update {} -> {}, 注释{};DDL={}", + spec.table, + spec.column, + data_type, + spec.data_type, + is_nullable, + if spec.nullable { "YES" } else { "NO" }, + column_default, + spec.default_value, + if extra.to_lowercase().contains("on update current_timestamp") { + "有" + } else { + "无" + }, + if spec.on_update { "需要" } else { "不需要" }, + if comment_differs { "已按目标口径同步" } else { "已一致/未校验" }, + spec.ddl + ); + Ok(true) +} + +/// 历史库列定义对齐入口:逐列对齐 + `organizations.parent_id` 索引/外键 +/// +/// 容错口径:**每一列独立 try**,某一列失败(锁冲突 / 权限 / 存量 NULL 无法转 NOT NULL) +/// 只记 ERROR 并继续处理后续列 —— 不能因为第一列失败就让整批对齐静默放弃。 +/// 返回的 `Err` 仅表示 `information_schema` 查询本身不可用(数据库已不可信)。 +async fn align_legacy_columns(pool: &MySqlPool) -> Result<(), sqlx::Error> { + let specs: Vec = LEGACY_COLUMN_TARGETS + .iter() + .map(|t| ColumnSpec::from_target(t.0, t.1, t.2)) + .collect(); + + let mut aligned: usize = 0; + for spec in &specs { + match ensure_column_type(pool, spec).await { + Ok(true) => aligned += 1, + Ok(false) => {} + Err(e) => tracing::error!( + "[migrate] 列定义对齐失败 {}.{}(跳过该列,继续启动)err={}", + spec.table, + spec.column, + e + ), + } + } + + match ensure_organizations_parent_fk(pool).await { + Ok(true) => aligned += 1, + Ok(false) => {} + Err(e) => tracing::error!( + "[migrate] organizations.parent_id 索引/外键补齐失败(服务继续启动)err={}", + e + ), + } + + // 纯注释口径(3 列):同样逐项独立 try,失败只记 ERROR + for (table, column, marker, ddl) in LEGACY_COLUMN_COMMENTS { + match ensure_column_comment(pool, table, column, marker, ddl).await { + Ok(true) => aligned += 1, + Ok(false) => {} + Err(e) => tracing::error!( + "[migrate] 列注释口径对齐失败 {}.{}(跳过该列,继续启动)err={}", + table, + column, + e + ), + } + } + + if aligned == 0 { + tracing::info!( + "历史库列定义对齐检查完成:{} 列定义 + {} 处注释口径 + organizations.parent_id 索引/外键全部已一致,未执行任何 DDL", + specs.len(), + LEGACY_COLUMN_COMMENTS.len() + ); + } else { + tracing::info!("历史库列定义对齐完成:本次执行了 {} 项 DDL", aligned); + } + Ok(()) +} + +/// `organizations.parent_id` 的**索引 + 自引用外键**补齐(幂等) +/// +/// 生产 `pms` 两者都有(`KEY parent_id` + `CONSTRAINT organizations_ibfk_1 +/// FOREIGN KEY (parent_id) REFERENCES organizations(id)`),老库 `pms_dev` 两者都没有 +/// ⇒ 组织树递归查询无法走索引,且删除/改名父组织时缺少引用完整性兜底。 +/// +/// 顺序:**先建索引、再加外键**。MySQL 加外键时若没有可用索引会自动建一个, +/// 但那样索引名由 MySQL 生成(`parent_id`),与既有命名规范不一致; +/// 显式先建 `idx_organizations_parent` 可以让外键直接复用它。 +/// +/// 返回 `Ok(true)` 表示本次至少执行了一项 DDL。 +/// +/// ⚠️ 外键加不上(存量脏数据引用了不存在的父组织 / 权限不足)时按 ERROR 上报, +/// **不硬来**:只记日志、不删数据、不改数据。 +async fn ensure_organizations_parent_fk(pool: &MySqlPool) -> Result { + /// 目标表名(白名单常量) + const TABLE: &str = "organizations"; + /// 目标列名(白名单常量) + const COLUMN: &str = "parent_id"; + /// 显式索引名(外键将复用该索引) + const INDEX: &str = "idx_organizations_parent"; + + // 1) 索引:只要存在「以 parent_id 为最左列」的索引即可(外键要求最左前缀) + let index_exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM information_schema.statistics \ + WHERE table_schema = DATABASE() AND table_name = ? \ + AND column_name = ? AND seq_in_index = 1", + ) + .bind(TABLE) + .bind(COLUMN) + .fetch_one(pool) + .await?; + + let mut did_ddl = false; + if index_exists.0 == 0 { + ensure_index(pool, TABLE, INDEX, COLUMN).await?; + did_ddl = true; + } + + // 2) 自引用外键 + let fk_exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE \ + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? \ + AND REFERENCED_TABLE_NAME = ?", + ) + .bind(TABLE) + .bind(COLUMN) + .bind(TABLE) + .fetch_one(pool) + .await?; + + if fk_exists.0 == 0 { + sqlx::query( + "ALTER TABLE organizations ADD CONSTRAINT fk_organizations_parent \ + FOREIGN KEY (parent_id) REFERENCES organizations(id)", + ) + .execute(pool) + .await?; + did_ddl = true; + tracing::info!( + "[migrate] 已补齐自引用外键 fk_organizations_parent (organizations.parent_id -> organizations.id)" + ); + } + + Ok(did_ddl) +} + /// 插入默认权限码 async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> { let perms: Vec<(&str, &str)> = vec![ @@ -825,7 +1336,7 @@ async fn grant_permissions_by_codes( #[cfg(test)] mod tests { - use super::SCHEMA_SQL; + use super::{ColumnSpec, LEGACY_COLUMN_COMMENTS, LEGACY_COLUMN_TARGETS, SCHEMA_SQL}; /// `SCHEMA_SQL` 中每条建表语句的固定前缀 const CREATE_PREFIX: &str = "CREATE TABLE IF NOT EXISTS "; @@ -903,4 +1414,197 @@ mod tests { ); assert_eq!(refs, vec!["other"]); } + + /// 解析器自检:目标定义原文 → 判定字段(类型/可空性/默认值/ON UPDATE/注释) + /// + /// 判定字段与 DDL 全部由目标定义原文推出。解析一旦错了,就会出现 + /// 「判定说不一致 → ALTER 跑了 → 下一轮判定仍说不一致 → 每次启动都 DDL」的死循环。 + #[test] + fn test_column_spec_from_target_parses_all_dimensions() { + // NOT NULL + 默认值 + ON UPDATE + let spec = ColumnSpec::from_target( + "users", + "updated_at", + "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", + ); + assert_eq!(spec.data_type, "DATETIME"); + assert!(!spec.nullable); + assert_eq!(spec.default_value, "CURRENT_TIMESTAMP"); + assert!(spec.on_update); + assert_eq!(spec.comment_marker, ""); + assert_eq!( + spec.ddl, + "ALTER TABLE users MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ); + + // 可空 + 注释 + 无默认值 + let spec = ColumnSpec::from_target( + "charge_orders", + "end_time", + "DATETIME NULL COMMENT '充电结束时间'", + ); + assert_eq!(spec.data_type, "DATETIME"); + assert!(spec.nullable); + assert_eq!(spec.default_value, ""); + assert!(!spec.on_update); + assert_eq!(spec.comment_marker, "充电结束时间"); + + // 数值列 + 注释(单位口径) + let spec = ColumnSpec::from_target( + "charge_records", + "energy", + "BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)'", + ); + assert_eq!(spec.data_type, "BIGINT"); + assert!(!spec.nullable); + assert_eq!(spec.default_value, "0"); + assert!(!spec.on_update); + assert_eq!(spec.comment_marker, "充电量(Wh)"); + } + + /// **建表语句与对齐清单必须同源**:清单里每一列的「目标定义」都要能在 + /// `SCHEMA_SQL` 对应表的建表语句里**原样找到** + /// + /// 这是「不要只改迁移不改建表」的守门测试:只改一边 ⇒ 新建库与老库再次分叉, + /// 正是本项目连踩多次的那类坑。 + #[test] + fn test_every_alignment_target_appears_verbatim_in_schema_sql() { + let defs = parse_table_definitions(); + assert!(!LEGACY_COLUMN_TARGETS.is_empty(), "对齐清单不能为空"); + for entry in LEGACY_COLUMN_TARGETS { + let (table, column, target) = *entry; + let stmt = defs + .iter() + .find(|(name, _)| name.as_str() == table) + .map(|(_, stmt)| *stmt) + .unwrap_or_else(|| panic!("SCHEMA_SQL 缺少建表语句 {}", table)); + assert!( + stmt.contains(&format!("{} {}", column, target)), + "SCHEMA_SQL 的 `{}` 建表语句里找不到对齐目标 `{} {}`(建表与迁移清单已分叉)", + table, + column, + target + ); + } + } + + /// 对齐清单必须覆盖已实测出问题的全部列(漏一个就等于该列漂移永不修复) + #[test] + fn test_alignment_targets_cover_known_broken_columns() { + let actual: Vec<(String, String)> = LEGACY_COLUMN_TARGETS + .iter() + .map(|t| (t.0.to_string(), t.1.to_string())) + .collect(); + + for expected in [ + // 测试环境导出失败的直接原因 + ("charge_records", "start_time"), + ("charge_records", "end_time"), + ("charge_records", "energy"), + ("energy_stats", "total_energy"), + ("download_tasks", "completed_at"), + ("download_tasks", "created_at"), + // 生产 `/api/charge-orders` 一有订单就 500 + ("charge_orders", "start_time"), + ("charge_orders", "end_time"), + ("charge_orders", "created_at"), + // `/api/device-logs`、`/api/users`、`/api/operation-logs`、下载中心 + ("device_logs", "created_at"), + ("users", "created_at"), + ("users", "updated_at"), + ("operation_logs", "created_at"), + // 组织树父列(注释口径统一) + ("organizations", "parent_id"), + ] { + assert!( + actual.contains(&(expected.0.to_string(), expected.1.to_string())), + "对齐清单缺少已确认出问题的列 {}.{}:实际 {:?}", + expected.0, + expected.1, + actual + ); + } + } + + /// 注释口径清单自检:`marker` 必须真的出现在 `ddl` 写进去的注释里 + /// + /// 否则就会出现「marker 永远匹配不上 → 每次启动都 ALTER」的死循环。 + /// 另外 `ddl` 必须显式写清可空性(这两张表的相关列本来就允许 NULL, + /// `MODIFY` 漏写 `NULL` 会把列改成 NOT NULL —— 那就是改结构而不是改注释了)。 + #[test] + fn test_legacy_column_comments_marker_matches_ddl() { + assert_eq!( + LEGACY_COLUMN_COMMENTS.len(), + 3, + "注释口径清单当前应恰好 3 条(batteries.brand/model、cabinets.address)" + ); + for entry in LEGACY_COLUMN_COMMENTS { + let (table, column, marker, ddl) = *entry; + let label = format!("{}.{}", table, column); + assert!( + ddl.contains(marker), + "{} 的 DDL 注释里不含 marker `{}`(会导致每次启动都重复 DDL):{}", + label, + marker, + ddl + ); + assert!( + ddl.starts_with("ALTER TABLE ") && ddl.contains(&format!("TABLE {} ", table)), + "{} 的 DDL 表名不对:{}", + label, + ddl + ); + assert!( + ddl.contains(&format!("MODIFY COLUMN {} ", column)), + "{} 的 DDL 列名不对:{}", + label, + ddl + ); + assert!( + ddl.contains(" NULL "), + "{} 的 DDL 未显式写 NULL(会把列改成 NOT NULL,超出「只改注释」的范围):{}", + label, + ddl + ); + } + } + + /// 建表语句里**不允许再出现 `TIMESTAMP` 列类型** + /// + /// `TIMESTAMP` 只与 `DateTime` 兼容,而本项目时间列在 Rust 侧全是 + /// `NaiveDateTime` / `Option` ⇒ 任何一处 TIMESTAMP 都会让 + /// 对应接口 500(实测:测试环境导出失败、生产订单列表 500)。 + /// + /// 判定按**词元**而不是子串:`ON UPDATE CURRENT_TIMESTAMP` / `DEFAULT CURRENT_TIMESTAMP` + /// 里的 `CURRENT_TIMESTAMP` 是完全合法的(`_` 也算词元字符,因此不会误判)。 + #[test] + fn test_schema_sql_contains_no_timestamp_column() { + for (table, stmt) in parse_table_definitions() { + let has_timestamp_type = stmt + .split(|c: char| !(c.is_alphanumeric() || c == '_')) + .any(|token| token == "TIMESTAMP"); + assert!( + !has_timestamp_type, + "建表语句 {} 仍含 TIMESTAMP 列类型(无法解码为 NaiveDateTime):{}", + table, stmt + ); + } + } + + /// 电量/能耗列的单位注释口径必须是 **Wh**(代码写入的就是 Wh) + #[test] + fn test_schema_sql_energy_columns_comment_unit_is_wh() { + assert!( + SCHEMA_SQL.contains("energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)'"), + "charge_records.energy 单位注释必须是 Wh" + ); + assert!( + SCHEMA_SQL.contains("total_energy BIGINT NOT NULL DEFAULT 0 COMMENT '总能耗(Wh)'"), + "energy_stats.total_energy 单位注释必须是 Wh" + ); + assert!( + !SCHEMA_SQL.contains("kWh"), + "建表语句里不允许出现 kWh(口径是 Wh,写进去的也是 Wh)" + ); + } } diff --git a/software/api-server/src/middleware/auth.rs b/software/api-server/src/middleware/auth.rs index f854507..ccbd9b5 100644 --- a/software/api-server/src/middleware/auth.rs +++ b/software/api-server/src/middleware/auth.rs @@ -53,6 +53,125 @@ pub struct CurrentUser { pub role_level: i32, /// 所属组织ID(总管理员为 None) pub organization_id: Option, + /// 有效数据范围(`user_scopes` 解析后的**项目 ID 白名单**) + /// + /// 空 = **不限制**(沿用组织隔离,与"没有配置过 scope"完全等价); + /// 非空 = 在组织隔离之上**再收窄**到这些项目(取交集,绝不放大)。 + /// 总管理员恒为空(豁免)。 + pub scope: UserScope, +} + +/// 用户**有效数据范围**(`user_scopes` 表解析结果,粒度到**项目级**) +/// +/// # 语义(产品决定,权威口径见 `AGENTS.md`「数据范围(user_scopes)」) +/// - 表里**没有任何记录** ⇒ 不限制(本结构为空 ⇒ [`Self::is_unrestricted`] 为真); +/// - **组织级**(`scope_type = 1`)⇒ 只允许配成**本人所属组织**,加载时展开为该组织下的全部项目; +/// - **项目级**(`scope_type = 2`)⇒ 项目必须存在且 `projects.organization_id` = 本人所属组织; +/// - **设备/柜子级**(`scope_type = 3`)⇒ **不参与鉴权**(范围粒度到项目为止),读取时直接忽略; +/// - 有记录但**一条有效范围都没有**(脏数据 / 设备级 / 已删除 / 跨组织)⇒ 视为"未配置"= 不限制, +/// 并记 WARN —— **绝不因为脏数据把用户锁死**。 +/// +/// # 为什么把组织级展开成项目 ID +/// 鉴权侧所有过滤最终都落在"项目"这一层(`cabinets.project_id` / `projects.id`), +/// 展开后**只有一种收窄形态**(`project_id IN (...)`), +/// 避免组织级/项目级两套 SQL 分支在几十个接口里各写一遍(写漏一个就是数据泄露)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UserScope { + /// 有效项目 ID 白名单(组织级 scope 已在加载时展开进来) + pub project_ids: Vec, +} + +impl UserScope { + /// 是否**不限制**(未配置 / 配置了但一条有效范围都没有) + pub fn is_unrestricted(&self) -> bool { + self.project_ids.is_empty() + } + + /// 项目 ID 列(如 `c.project_id` / `p.id`)的收窄条件 + /// + /// 返回值与 [`org_condition`] 同构:`(带 " AND " 前缀的片段, 绑定值)`, + /// 不限制时返回 `(空串, 空向量)` ⇒ 调用方逻辑与组织隔离完全一致。 + pub fn project_condition(&self, project_col: &str) -> (String, Vec) { + if self.is_unrestricted() { + return (String::new(), Vec::new()); + } + let placeholders = vec!["?"; self.project_ids.len()].join(","); + ( + format!(" AND {} IN ({})", project_col, placeholders), + self.project_ids.iter().map(|id| id.to_string()).collect(), + ) + } + + /// 柜子 ID 列(如 `cr.cabinet_id` / `dl.cabinet_id`)的收窄条件:柜子所属项目必须在范围内 + pub fn cabinet_condition(&self, cabinet_col: &str) -> (String, Vec) { + if self.is_unrestricted() { + return (String::new(), Vec::new()); + } + let placeholders = vec!["?"; self.project_ids.len()].join(","); + ( + format!( + " AND {} IN (SELECT c.id FROM cabinets c WHERE c.project_id IN ({}))", + cabinet_col, placeholders + ), + self.project_ids.iter().map(|id| id.to_string()).collect(), + ) + } + + /// 内存判定:某个项目是否落在范围内(用于 h5 列表的内存过滤与单资源校验) + /// + /// `project_id` 为 `None`(未划拨到项目的孤儿设备)在**有限制**时一律不在范围内。 + pub fn allows_project(&self, project_id: Option) -> bool { + if self.is_unrestricted() { + return true; + } + matches!(project_id, Some(pid) if self.project_ids.contains(&pid)) + } +} + +impl CurrentUser { + /// **实际生效**的数据范围:总管理员(`role_level >= 2`)豁免,恒为"不限制"。 + /// + /// 为什么要有这个方法:路由里的过滤条件一律用它,而不是直接用 `self.scope` —— + /// 中间件已对超管短路,但路由层再显式豁免一道,即便将来有人在别处构造 + /// `CurrentUser`(单测 / 内部调用)也不会让超管被误收窄。 + pub fn effective_scope(&self) -> UserScope { + if self.role_level >= SUPER_ADMIN_ROLE_LEVEL { + UserScope::default() + } else { + self.scope.clone() + } + } +} + +/// 把 ` AND xxx` 形式的条件片段追加到**条件列表**(不含 `WHERE` 关键字)上 +/// +/// 已有列表为空时自动去掉前导 ` AND `(避免生成 ` AND x` 这种非法开头); +/// 片段本身为空(不限制)时原样返回。 +/// ⚠️ 只适用于「调用方自己补 `WHERE `」的场景;拼**完整 WHERE 子句**请用 [`append_where`]。 +pub fn append_condition(where_clause: &mut String, fragment: &str) { + if fragment.is_empty() { + return; + } + if where_clause.trim().is_empty() { + *where_clause = fragment.trim_start_matches(" AND ").to_string(); + } else { + where_clause.push_str(fragment); + } +} + +/// 把 ` AND xxx` 形式的条件片段追加到**完整 WHERE 子句**上 +/// +/// 子句为空时**补上 `WHERE `**(而不是只写条件本身)—— 因为调用方会把整串直接拼进 +/// `SELECT ... {clause} ORDER BY ...`,少了关键字就是 SQL 语法错误(1064)。 +pub fn append_where(where_clause: &mut String, fragment: &str) { + if fragment.is_empty() { + return; + } + if where_clause.trim().is_empty() { + *where_clause = format!("WHERE {}", fragment.trim_start_matches(" AND ")); + } else { + where_clause.push_str(fragment); + } } /// 认证中间件 — 从请求头提取 JWT,解析用户信息并注入请求扩展 @@ -97,21 +216,34 @@ pub async fn auth_middleware( let claims = token_data.claims; - // 查询用户权限码集合 - let permissions = match load_user_permissions(pool, claims.sub).await { - Ok(perms) => perms, + // 权限码集合 与 「角色等级 + 组织ID」互不依赖 ⇒ 并发查询,省一次往返 + let (permissions, role_and_org) = match tokio::try_join!( + load_user_permissions(pool, claims.sub), + load_user_role_and_org(pool, claims.sub), + ) { + Ok((perms, role_org)) => (perms, role_org), Err(e) => { - tracing::error!("查询用户权限失败: {}", e); + tracing::error!("查询用户权限/角色失败: {}", e); return AppError::Internal("权限查询失败".into()).into_response(); } }; - // 查询用户角色等级和组织ID - let (role_level, organization_id) = match load_user_role_and_org(pool, claims.sub).await { - Ok(data) => data, - Err(e) => { - tracing::error!("查询用户角色失败: {}", e); - return AppError::Internal("角色查询失败".into()).into_response(); + let (role_level, organization_id) = role_and_org; + + // 加载有效数据范围(`user_scopes`)。 + // - 总管理员(role_level >= 2)**豁免**:恒为"不限制",连库都不查; + // - 只有配过 scope 的客户侧用户才会多出这条查询(见 `load_user_scopes` 的首查短路)。 + let scope = if role_level >= SUPER_ADMIN_ROLE_LEVEL { + UserScope::default() + } else { + match load_user_scopes(pool, claims.sub, organization_id).await { + Ok(scope) => scope, + Err(e) => { + // 失败安全:范围查询失败**不能让请求 500**(否则一次脏数据/超时就全网不可用)。 + // 保守退回"不限制",与"未配置"同义 —— 组织隔离仍然生效,不会跨组织泄露。 + tracing::error!("查询用户数据范围失败,按「未配置=不限制」继续: {}", e); + UserScope::default() + } } }; @@ -122,6 +254,7 @@ pub async fn auth_middleware( permissions, role_level, organization_id, + scope, }; // 拆分请求,插入扩展,重新组合 @@ -284,6 +417,82 @@ async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32, Ok(result.unwrap_or((0, None))) } +/// 加载用户的**有效数据范围**(`user_scopes` → 扁平的**项目 ID 白名单**) +/// +/// # 四种情形的行为(产品决定,权威口径见 `AGENTS.md`「数据范围(user_scopes)」) +/// | 库里的状态 | 结果 | 行为 | +/// |---|---|---| +/// | 一条记录都没有 | 不限制 | 沿用组织隔离,与"今天"完全一致(**向后兼容的关键**) | +/// | 组织级(1) | 展开成项目 | 只接受 `scope_id = users.organization_id`(本人组织),展开为该组织全部项目 | +/// | 项目级(2) | 直接采用 | 项目必须存在,且 `projects.organization_id = users.organization_id` | +/// | 设备/柜子级(3) | **忽略** | 范围粒度到项目为止,不参与鉴权(当不存在) | +/// | 有记录但有效范围为空(脏数据/设备级/跨组织/项目已删) | 不限制 + WARN | 按"未配置"处理,**绝不把用户锁死** | +/// +/// # 为什么"跨组织/不存在的 scope_id"在读取时被忽略而不是报错 +/// 历史脏数据不该让**整条鉴权链**失败(那会变成所有接口 500 或全员锁死)。 +/// 写入侧(`routes/users_perm.rs::set_user_scopes`)已经做了**强校验**(不合法直接 400), +/// 读取这里的忽略只是对历史数据的保守兜底。 +/// +/// # 性能 +/// - 无 scope 的用户:1 次 `COUNT(*)`(走 `user_scopes.user_id` 外键索引)后即返回; +/// - 有 scope 的客户侧用户:2 次查询(`COUNT(*)` + 一次 UNION 取有效项目); +/// - 总管理员:**0 次**(中间件里直接短路)。 +pub async fn load_user_scopes( + pool: &MySqlPool, + user_id: i64, + organization_id: Option, +) -> Result { + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_scopes WHERE user_id = ?") + .bind(user_id) + .fetch_one(pool) + .await?; + + // 未配置任何 scope ⇒ 不限制(**绝不解成"无权限"**,否则现存所有客户账号会被一次性锁死) + if total == 0 { + return Ok(UserScope::default()); + } + + let project_ids: Vec = match organization_id { + Some(org_id) => { + sqlx::query_scalar( + r#" + SELECT DISTINCT t.pid FROM ( + SELECT p.id AS pid + FROM user_scopes us + JOIN projects p ON p.organization_id = us.scope_id + WHERE us.user_id = ? AND us.scope_type = 1 AND us.scope_id = ? + UNION + SELECT p.id AS pid + FROM user_scopes us + JOIN projects p ON p.id = us.scope_id + WHERE us.user_id = ? AND us.scope_type = 2 AND p.organization_id = ? + ) t + "#, + ) + .bind(user_id) + .bind(org_id) + .bind(user_id) + .bind(org_id) + .fetch_all(pool) + .await? + } + // 无组织归属的客户侧账号:组织隔离本身已给出空集(`org_condition` 返回 `1=0`), + // 这里不展开任何项目,也就不会因为 scope 反而"放开"任何数据。 + None => Vec::new(), + }; + + if project_ids.is_empty() { + tracing::warn!( + user_id, + scope_rows = total, + "user_scopes 存在记录但无一条有效范围(脏数据/设备级/跨组织/项目已删除),按「未配置=不限制」处理" + ); + return Ok(UserScope::default()); + } + + Ok(UserScope { project_ids }) +} + /// 生成 JWT token(24小时有效期) /// /// `secret` 由调用方从 AppState.jwt_secret 传入,禁止硬编码回退。 @@ -338,6 +547,10 @@ pub fn verify_password(password: &str, hash: &str) -> Result { /// 返回角色等级 >= 2 时「总管理员」看全部 /// 否则按 organization_id 过滤(子查询方式,通过 cabinet → project → org 链路) +/// +/// ⚠️ 这只表达**组织隔离**。数据范围([`UserScope`])是**独立叠加**的第二道收窄, +/// 调用方必须在同一处 WHERE 里再拼上 [`UserScope::cabinet_condition`] / +/// [`UserScope::project_condition`](两者取交集),否则配了 scope 的用户会被看漏。 pub fn org_condition(user: &CurrentUser, cabinet_table: &str) -> (String, Vec) { if user.role_level >= 2 { return (String::new(), vec![]); @@ -390,6 +603,7 @@ mod tests { role_level: 2, organization_id: None, permissions: HashSet::new(), + scope: UserScope::default(), }; let (cond, params) = org_condition(&user, "cr"); assert!(cond.is_empty()); @@ -405,6 +619,7 @@ mod tests { role_level: 1, organization_id: Some(42), permissions: HashSet::new(), + scope: UserScope::default(), }; let (cond, params) = org_condition(&user, "cr"); assert!(cond.contains("organization_id = ?"), "应有组织过滤条件"); @@ -420,6 +635,7 @@ mod tests { role_level: 0, organization_id: None, permissions: HashSet::new(), + scope: UserScope::default(), }; let (cond, _params) = org_condition(&user, "cr"); assert!(cond.contains("1=0"), "无组织应返回 1=0"); @@ -502,6 +718,7 @@ mod tests { role_level: SUPER_ADMIN_ROLE_LEVEL, organization_id: None, permissions: HashSet::new(), + scope: UserScope::default(), }; assert!(check_permission(&user, "device:view").is_ok(), "超管应短路放行"); } @@ -515,7 +732,71 @@ mod tests { role_level: 0, organization_id: Some(1), permissions: HashSet::new(), + scope: UserScope::default(), }; assert!(check_permission(&user, "device:view").is_err(), "空权限集应拒绝"); } + + // ----------------------------------------------------------------------- + // 数据范围(user_scopes)语义 + // ----------------------------------------------------------------------- + + /// 未配置任何 scope(默认构造)⇒ **不限制**(向后兼容的根:绝不能解成"无权限") + #[test] + fn test_user_scope_default_is_unrestricted() { + let scope = UserScope::default(); + assert!(scope.is_unrestricted(), "未配置必须是「不限制」"); + let (cond, binds) = scope.project_condition("c.project_id"); + assert!(cond.is_empty(), "不限制时不得产生任何 SQL 条件:{}", cond); + assert!(binds.is_empty()); + let (cond, binds) = scope.cabinet_condition("cr.cabinet_id"); + assert!(cond.is_empty()); + assert!(binds.is_empty()); + } + + /// 配置了项目范围 ⇒ 收窄条件必须落在项目列上,且绑定值与范围一一对应 + #[test] + fn test_user_scope_project_condition_narrows_by_project() { + let scope = UserScope { project_ids: vec![7, 9] }; + assert!(!scope.is_unrestricted()); + + let (cond, binds) = scope.project_condition("c.project_id"); + assert_eq!(cond, " AND c.project_id IN (?,?)"); + assert_eq!(binds, vec!["7", "9"]); + + // 柜子列收窄:经 cabinets.project_id 子查询,绝不退化成"全部柜子" + let (cond, binds) = scope.cabinet_condition("cr.cabinet_id"); + assert!(cond.contains("cr.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (?,?))")); + assert_eq!(binds, vec!["7", "9"]); + } + + /// 内存判定:范围内项目放行,范围外项目拒绝,未划拨(None)在有限制时也拒绝 + #[test] + fn test_user_scope_allows_project() { + let unrestricted = UserScope::default(); + assert!(unrestricted.allows_project(Some(1))); + assert!(unrestricted.allows_project(None), "不限制时未划拨设备也不拦(沿用既有组织口径)"); + + let scope = UserScope { project_ids: vec![7] }; + assert!(scope.allows_project(Some(7)), "范围内项目应放行"); + assert!(!scope.allows_project(Some(8)), "范围外项目必须拒绝"); + assert!(!scope.allows_project(None), "有限制时未划拨设备一律不在范围内"); + } + + /// 追加条件助手:空片段时不得留下前导 ` AND `(否则 SQL 语法错误) + #[test] + fn test_append_condition_handles_empty_clause() { + let mut clause = String::new(); + append_condition(&mut clause, " AND c.project_id IN (?)"); + assert_eq!(clause, "c.project_id IN (?)", "空片段应去掉前导 AND"); + + let mut clause = "WHERE 1=1".to_string(); + append_condition(&mut clause, " AND c.project_id IN (?)"); + assert_eq!(clause, "WHERE 1=1 AND c.project_id IN (?)"); + + // 不限制(空片段)时原样不改 + let mut clause = "WHERE 1=1".to_string(); + append_condition(&mut clause, ""); + assert_eq!(clause, "WHERE 1=1"); + } } diff --git a/software/api-server/src/routes/auth.rs b/software/api-server/src/routes/auth.rs index cf69fe9..abe62b0 100644 --- a/software/api-server/src/routes/auth.rs +++ b/software/api-server/src/routes/auth.rs @@ -8,6 +8,7 @@ //! - PUT /api/auth/avatar — 更新用户头像 URL use axum::extract::{Multipart, State}; +use axum::http::HeaderMap; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; @@ -17,11 +18,114 @@ use uuid::Uuid; const AVATAR_DIR: &str = "uploads/avatars"; use reqwest::Client; +use super::upload_guard; use crate::captcha; use crate::commands::AppState; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; +/// 登录失败计数阈值:同一手机号 / 同一 IP 在窗口期内最多允许的失败次数 +const LOGIN_FAIL_MAX: i64 = 5; +/// 登录失败计数窗口(秒)—— Redis TTL,到期自动清零 +const LOGIN_FAIL_WINDOW_SECS: u64 = 900; +/// challenge 发码限流:同一 IP 每分钟最多请求次数(免认证接口,防 CPU 打满) +const CHALLENGE_RATE_MAX: i64 = 10; +/// challenge 发码限流窗口(秒) +const CHALLENGE_RATE_WINDOW_SECS: u64 = 60; +/// 登录失败统一文案(锁定期同样返回它,不泄露"账号已锁"等可枚举信息) +const LOGIN_FAIL_MESSAGE: &str = "手机号或密码错误"; +/// 客户端 IP 请求头 —— 只信任 nginx `proxy_set_header X-Real-IP $remote_addr` 注入的值, +/// 不读取客户端可任意伪造的 `X-Forwarded-For` 链 +const CLIENT_IP_HEADER: &str = "x-real-ip"; +/// 取不到 IP 时的兜底标识(无头直连请求共用一个计数桶) +const UNKNOWN_CLIENT_IP: &str = "unknown"; + +/// 从请求头取客户端 IP(仅信任 X-Real-IP,取不到时用固定兜底值) +fn client_ip(headers: &HeaderMap) -> String { + headers + .get(CLIENT_IP_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(UNKNOWN_CLIENT_IP) + .to_string() +} + +/// Redis 自增计数:键首次创建时设置 TTL(固定窗口,不因后续自增而延长) +async fn incr_with_ttl(state: &AppState, key: &str, ttl_secs: u64) -> Result { + let mut conn = state.redis.clone(); + let count: i64 = redis::cmd("INCR").arg(key).query_async(&mut conn).await?; + if count == 1 { + let _: () = redis::cmd("EXPIRE") + .arg(key) + .arg(ttl_secs) + .query_async(&mut conn) + .await?; + } + Ok(count) +} + +/// 读取计数(键不存在按 0);Redis 异常时按 0 处理(fail-open,仅记 ERROR 日志) +/// +/// fail-open 的理由:登录链路本身已依赖 Redis(验证码校验失败即拒绝), +/// 这里不重复阻断,避免缓存抖动导致全体用户无法登录。 +async fn read_counter(state: &AppState, key: &str) -> i64 { + let mut conn = state.redis.clone(); + match redis::cmd("GET") + .arg(key) + .query_async::>(&mut conn) + .await + { + Ok(Some(v)) => v.parse::().unwrap_or(0), + Ok(None) => 0, + Err(e) => { + tracing::error!(err = %e, key = %key, "读取登录失败计数失败,按 0 处理"); + 0 + } + } +} + +/// 清除计数(登录成功后调用);失败仅记 ERROR 日志,不影响登录结果 +async fn clear_counter(state: &AppState, key: &str) { + let mut conn = state.redis.clone(); + if let Err(e) = redis::cmd("DEL").arg(key).query_async::<()>(&mut conn).await { + tracing::error!(err = %e, key = %key, "清除登录失败计数失败"); + } +} + +/// 记录一次登录失败:手机号 + IP 双维度各自自增,达到阈值记 WARN +/// +/// 返回两个维度的较大计数(供调用方日志使用)。 +async fn record_login_failure( + state: &AppState, + phone_key: &str, + ip_key: &str, + phone: &str, + ip: &str, +) -> i64 { + let phone_fails = match incr_with_ttl(state, phone_key, LOGIN_FAIL_WINDOW_SECS).await { + Ok(n) => n, + Err(e) => { + tracing::error!(err = %e, key = %phone_key, "手机号维度失败计数写入失败"); + 0 + } + }; + let ip_fails = match incr_with_ttl(state, ip_key, LOGIN_FAIL_WINDOW_SECS).await { + Ok(n) => n, + Err(e) => { + tracing::error!(err = %e, key = %ip_key, "IP 维度失败计数写入失败"); + 0 + } + }; + if phone_fails >= LOGIN_FAIL_MAX || ip_fails >= LOGIN_FAIL_MAX { + tracing::warn!( + phone = %phone, ip = %ip, phone_fails, ip_fails, window_secs = LOGIN_FAIL_WINDOW_SECS, + "登录失败次数达到阈值,该维度已锁定" + ); + } + phone_fails.max(ip_fails) +} + /// 登录请求体 #[derive(Debug, Deserialize)] pub(crate) struct LoginInput { @@ -38,9 +142,20 @@ pub(crate) struct LoginInput { /// GET /api/auth/challenge — 获取登录滑块验证码 /// /// 返回背景图(带缺口)和拼图块图,前端拖动对齐后提交偏移量。 +/// 该接口免认证,故按客户端 IP 限流(10 次/分钟),并配合 `captcha` 内部的渲染并发闸门, +/// 避免被用来打满 CPU(第二道限流由 nginx `limit_req` 承担)。 pub async fn get_challenge( State(state): State, + headers: HeaderMap, ) -> Result, AppError> { + let ip = client_ip(&headers); + let rate_key = format!("auth:challenge:rate:{}", ip); + let count = incr_with_ttl(&state, &rate_key, CHALLENGE_RATE_WINDOW_SECS).await?; + if count > CHALLENGE_RATE_MAX { + tracing::warn!(ip = %ip, count, max = CHALLENGE_RATE_MAX, "challenge 请求超过限流阈值,已拒绝"); + return Err(AppError::TooManyRequests("请求过于频繁,请稍后再试".into())); + } + let data = captcha::create_challenge(&state.redis_url).await?; tracing::debug!("[challenge] 生成 cid={}", data.challenge_id); @@ -56,8 +171,13 @@ pub async fn get_challenge( } /// POST /api/auth/login — 用户登录 +/// +/// 防暴力破解:按「手机号 + 客户端 IP」双维度计数(Redis,TTL 15 分钟), +/// 任一维度达到 5 次失败即锁定该维度;锁定期与密码错误返回**完全一致**的文案, +/// 不泄露账号是否存在 / 是否被锁。登录成功后两个维度计数清零。 pub async fn login( State(state): State, + headers: HeaderMap, Json(input): Json, ) -> Result, AppError> { let pool = &state.mysql; @@ -66,6 +186,21 @@ pub async fn login( return Err(AppError::BadRequest("手机号和密码不能为空".into())); } + let ip = client_ip(&headers); + let phone_key = format!("auth:fail:phone:{}", input.phone); + let ip_key = format!("auth:fail:ip:{}", ip); + + // 锁定检查放在验证码之前:锁定期直接拒绝,不消耗验证码、不做图像/DB 开销 + let phone_fails = read_counter(&state, &phone_key).await; + let ip_fails = read_counter(&state, &ip_key).await; + if phone_fails >= LOGIN_FAIL_MAX || ip_fails >= LOGIN_FAIL_MAX { + tracing::warn!( + phone = %input.phone, ip = %ip, phone_fails, ip_fails, + "登录已锁定,拒绝本次尝试(返回统一文案)" + ); + return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into())); + } + // 校验滑块验证码(防暴力破解 + 人机验证) if input.challenge_id.is_empty() { return Err(AppError::BadRequest("缺少登录验证参数".into())); @@ -84,8 +219,14 @@ pub async fn login( .fetch_optional(pool) .await?; - let (id, name, phone, stored_password, status, role_level, organization_id) = - user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?; + let Some((id, name, phone, stored_password, status, role_level, organization_id)) = user_row + else { + // 账号不存在也计入失败:避免用"是否存在"做账号枚举 + let fails = + record_login_failure(&state, &phone_key, &ip_key, &input.phone, &ip).await; + tracing::warn!(phone = %input.phone, ip = %ip, fails, reason = "user_not_found", "登录失败"); + return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into())); + }; // 检查账户状态 if status == 0 { @@ -96,9 +237,16 @@ pub async fn login( let valid = auth::verify_password(&input.password, &stored_password) .map_err(|_| AppError::Internal("密码验证失败".into()))?; if !valid { - return Err(AppError::Unauthorized("手机号或密码错误".into())); + let fails = record_login_failure(&state, &phone_key, &ip_key, &phone, &ip).await; + tracing::warn!(phone = %phone, ip = %ip, fails, reason = "bad_password", "登录失败"); + return Err(AppError::Unauthorized(LOGIN_FAIL_MESSAGE.into())); } + // 登录成功:双维度失败计数清零 + clear_counter(&state, &phone_key).await; + clear_counter(&state, &ip_key).await; + tracing::info!(user_id = id, phone = %phone, ip = %ip, "登录成功"); + // 生成 JWT token let token = auth::generate_token(id, &phone, &state.jwt_secret)?; @@ -440,14 +588,9 @@ pub async fn upload_avatar( return Err(AppError::BadRequest("头像文件不能超过 2MB".into())); } - // 白名单扩展名 - if ext.is_empty() { - ext = ".jpg".to_string(); - } - let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]; - if !allowed.contains(&ext.as_str()) { - ext = ".jpg".to_string(); - } + // 扩展名白名单 + 文件魔数嗅探(拒绝 .svg/.htm 等可脚本化类型,防存储型 XSS) + // 返回值是**按内容嗅探出的**规范扩展名,保证落盘后缀与真实内容一致 + let ext = upload_guard::validate_image_upload(&data, &ext)?; // 保存文件 let dir = std::path::Path::new(AVATAR_DIR); diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index 8ff1f94..bc84671 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -56,6 +56,9 @@ impl From for CabinetRow { rssi: None, pow_fail_dc: None, pow_fail_ac: None, + // 柜级位域由 attach_realtime_status 从 Redis 补取(取不到保持 null) + status1: None, + status2: None, is_online: None, org_disabled: None, } @@ -80,6 +83,15 @@ const PROJECT_CABINET_COUNTS_SQL: &str = "SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id"; /// 设备列表基础 SQL:联表查询获取通道状态统计(子查询 idle_count/charging_count/... 原样保留) +/// +/// ⚠️ **口径说明(本次产品决定)**:`compartments.status` 只表达**占用**语义 +/// (`0 空闲 / 1 充电中`,这正是平台实际写入的两个值),"充满/故障"不落库。 +/// 因此: +/// - `idle_count` / `charging_count` 两条子查询(`comp.status = 0 / 1`)**保持原样**,语义正确; +/// - `full_count` / `fault_count` 两条子查询(`comp.status = 2 / 3`)在 DB 里**恒为 0** +/// (全仓没有任何写入点会写 2/3),本次采用「**SQL 保留、结果由派生值覆盖**」方案: +/// 这两列仍被 SELECT,但响应值一律由 [`apply_derived_counts`] 从通道实时状态派生后覆盖, +/// 详见 [`ChannelCounts`] 的注释。 const CABINET_FILTER_BASE_SQL: &str = " SELECT c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, @@ -104,10 +116,12 @@ const CABINET_FILTER_BASE_SQL: &str = " FROM cabinets c "; -/// 设备归属校验(非总管理员):设备必须属于当前用户组织 +/// 设备归属校验(非总管理员):设备必须属于当前用户组织 **且落在其数据范围内** +/// +/// 数据范围未配置时该附加条件为空串 ⇒ 与今天的行为完全一致(向后兼容)。 const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \ JOIN projects p ON c.project_id = p.id \ - WHERE c.imei = ? AND p.organization_id = ?)"; + WHERE c.imei = ? AND p.organization_id = ?{})"; // --------------------------------------------------------------------------- // 组织树 @@ -274,6 +288,10 @@ pub async fn filter_cabinets( } /// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致) +/// +/// **数据范围叠加**:组织隔离给出"能看到哪个组织",`CurrentUser::scope` 给出 +/// "在这个组织里还能看哪些项目"(未配置 = 不限制)。两者**取交集**, +/// 收窄条件统一落在 `c.project_id` 上(粒度到项目为止,不往下钻到单台柜子)。 fn build_filter_where( params: &CabinetFilterParams, user: &CurrentUser, @@ -295,12 +313,20 @@ fn build_filter_where( }; // 已绑定/未绑定过滤(绑定 = 分配到项目) - let where_clause = match params.bound.as_deref() { + let mut where_clause = match params.bound.as_deref() { Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"), Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"), _ => where_clause, }; + // 数据范围(项目级)收窄:占位符在 SQL 文本中位于最后 ⇒ 绑定值也必须追加在最后。 + // 用 `effective_scope()`(而不是 `user.scope`):**总管理员恒为"不限制"**, + // 即便别处构造的 CurrentUser 带着非空 scope 也不会把工厂自己收窄。 + let mut binds = binds; + let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id"); + auth::append_where(&mut where_clause, &scope_cond); + binds.extend(scope_binds); + (where_clause, binds) } @@ -346,12 +372,31 @@ async fn fetch_filter_page( Ok(rows) } +/// 设备列表 pipeline 中**每个设备**下发的 `HGET` 字段个数。 +/// +/// ⚠️ **命令顺序与下标一一对应**:`online`(0) / `rssi`(1) / `pow_fail_dc`(2) / +/// `pow_fail_ac`(3) / `cabinet_status1`(4) / `cabinet_status2`(5)。 +/// 该常量是 [`attach_realtime_status`] 里 `base = i * DEVICE_LIST_FIELDS` 的唯一依据, +/// **新增字段必须同步改这里、命令顺序与解析下标**(只能追加在末尾)。 +const DEVICE_LIST_FIELDS: usize = 6; + +/// 柜级 hex 位域取值:`None` / 空串 / 纯空白都算"无数据"(→ 响应 `null`), +/// **绝不落成 `"00"`**(`"00"` 是"全部正常"的有效值,与"没数据"必须区分)。 +/// 非空值**原样返回**(不解析、不改写大小写 —— 前端两端有统一的 hex 解析纯函数)。 +fn pick_non_empty_hex(raw: Option<&Option>) -> Option { + raw.and_then(|v| v.as_ref()) + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + /// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返) async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Result<(), AppError> { // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection let mut conn = open_redis_conn(state).await?; - // 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段) + // 用Pipeline一次发所有HGET命令(每个设备1次HGET取6个字段) + // ⚠️ 命令顺序与下面解析的下标一一对应:online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2 let mut pipe = redis::pipe(); for row in rows.iter() { let key = format!("device:{}", row.imei); @@ -359,14 +404,17 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re pipe.cmd("HGET").arg(&key).arg("rssi"); pipe.cmd("HGET").arg(&key).arg("pow_fail_dc"); pipe.cmd("HGET").arg(&key).arg("pow_fail_ac"); + // 柜级状态位(协议 §3.8):原样透出 hex,取不到 → null(不落 "00") + pipe.cmd("HGET").arg(&key).arg("cabinet_status1"); + pipe.cmd("HGET").arg(&key).arg("cabinet_status2"); } // 一次发送,获取所有结果 let results: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); - // 每4个一组解析 + // 每6个一组解析 for (i, row) in rows.iter_mut().enumerate() { - let base = i * 4; + let base = i * DEVICE_LIST_FIELDS; row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1"); row.rssi = results .get(base + 1) @@ -380,11 +428,155 @@ async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Re .get(base + 3) .and_then(|v| v.as_ref()) .map(|v| v == "1"); + row.status1 = pick_non_empty_hex(results.get(base + 4)); + row.status2 = pick_non_empty_hex(results.get(base + 5)); } + // 通道级:按柜读取通道状态 → 派生「充满数 / 故障数」并覆盖 SQL 的返回值 + apply_derived_counts(state, rows).await; + Ok(()) } +/// 按柜派生「充满数 / 故障数」并**覆盖** SQL 的返回值 +/// +/// - 依据:通道实时状态 `batt_status` / `pow_status` → [`derive_channel_flags`], +/// 与设备实时详情接口**同一套唯一权威推导规则**(优先级 fault > full > charging > idle); +/// - **拿不到 Redis 数据时(设备离线 / 该通道无键 / Redis 不可用)无法判定,按 0 计**, +/// 绝不猜测、也不回落到 SQL 的占位值(那两个值在 DB 里恒为 0,回落没有意义); +/// - `idle_count` / `charging_count` **不动**:它们是 DB 的占用语义(0/1),本来就是对的。 +async fn apply_derived_counts(state: &AppState, rows: &mut [CabinetRow]) { + let cabinet_ids: Vec = rows.iter().map(|r| r.id).collect(); + let entries = match fetch_channel_keys(&state.mysql, &cabinet_ids).await { + Ok(entries) => entries, + Err(e) => { + tracing::error!( + "[cabinets] 通道 key 查询失败,充满/故障计数按 0 计(离线口径)err={}", + e + ); + Vec::new() + } + }; + let counts = derive_counts_by_cabinet(state, &entries).await; + + for row in rows.iter_mut() { + // `ChannelCounts = (idle, charging, full, fault)`:只取后两位覆盖响应 + let (_, _, full, fault) = counts.get(&row.id).copied().unwrap_or((0, 0, 0, 0)); + row.full_count = Some(full); + row.fault_count = Some(fault); + } +} + +/// 通道级 Redis key 查询行:`(柜子ID, IMEI, 仓控板号, 通道号)` +type ChannelKeyRow = (i64, String, i8, i8); + +/// 通道状态 pipeline 每次 HMGET 的字段(下标顺序即 [`derive_channel_flags`] 的入参顺序) +const CHANNEL_FLAG_FIELDS: [&str; 2] = ["batt_status", "pow_status"]; + +/// 查询这批柜子的通道级 Redis key,返回 `(柜子ID, device:{imei}:board:{板号}:ch:{通道号})` +/// +/// 为什么按 DB 的「板号 + 通道号」直接拼 key,而不是 `KEYS device:{imei}:board:*:ch:*`: +/// `KEYS` 是**全库扫描**,列表页 12~100 个柜就是 12~100 次全库扫描(Redis 单线程会被打满); +/// 而通道键的构成与设备上报写入侧完全一致(`workers/report_worker.rs::channel_status_key`: +/// 板号/通道号十进制不补零),按 DB 拼 key 精确且零扫描。 +pub(crate) async fn fetch_channel_keys( + db: &sqlx::MySqlPool, + cabinet_ids: &[i64], +) -> Result, AppError> { + if cabinet_ids.is_empty() { + return Ok(Vec::new()); + } + + let placeholders = vec!["?"; cabinet_ids.len()].join(","); + let sql = format!( + r#"SELECT c.id, c.imei, b.board_id, comp.channel_id + FROM cabinets c + JOIN cabin_boards b ON b.cabinet_id = c.id + JOIN compartments comp ON comp.cabin_board_id = b.id + WHERE c.id IN ({}) + ORDER BY c.id, b.board_id, comp.channel_id"#, + placeholders + ); + let mut query = sqlx::query_as::<_, ChannelKeyRow>(&sql); + for id in cabinet_ids { + query = query.bind(id); + } + let rows: Vec = query.fetch_all(db).await?; + + Ok(rows + .into_iter() + // IMEI 为空的柜子拼不出有效通道 key(`device::board:1:ch:1`),直接跳过 + .filter(|(_, imei, _, _)| !imei.is_empty()) + .map(|(cabinet_id, imei, board_id, channel_id)| { + ( + cabinet_id, + format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id), + ) + }) + .collect()) +} + +/// 批量读取通道状态并按柜聚合出四计数,返回 `柜子ID → ChannelCounts` +/// +/// **离线策略(产品口径)**:通道读不到数据(键不存在 / 字段缺失)时该通道按派生 idle 记账, +/// 因此设备离线、通道从未上报时 `full` / `fault` 恒为 0 —— **离线无法判定,不猜测**; +/// 表里没有的柜子同样按全 0 处理(调用方 `unwrap_or_default`)。 +/// +/// ⚠️ 通道级 Hash 由上报链路写入后**没有 TTL**,设备离线后仍会残留最后一次上报值, +/// 所以本口径是「最后一次上报的通道状态」而非严格「在线实时」;若要收紧为仅在线设备, +/// 需额外用 `device:{imei}`(TTL 300s)是否存在做闸门(本次未做,见报告待决策项)。 +/// +/// Redis 不可用 / pipeline 失败只记 ERROR 并返回空表(等价于全部无数据 → 充满/故障记 0), +/// 绝不因此让列表接口整体失败。 +pub(crate) async fn derive_counts_by_cabinet( + state: &AppState, + entries: &[(i64, String)], +) -> HashMap { + if entries.is_empty() { + return HashMap::new(); + } + + let mut conn = match open_redis_conn(state).await { + Ok(conn) => conn, + Err(e) => { + tracing::error!("[cabinets] Redis 连接失败,充满/故障计数按 0 计 err={}", e); + return HashMap::new(); + } + }; + + // 每个通道 1 次 HMGET(2 个字段),全部走一次 pipeline + let mut pipe = redis::pipe(); + for (_, key) in entries { + pipe.cmd("HMGET").arg(key).arg(&CHANNEL_FLAG_FIELDS); + } + let results: Vec>> = match pipe.query_async(&mut conn).await { + Ok(results) => results, + Err(e) => { + tracing::error!("[cabinets] 通道状态读取失败,充满/故障计数按 0 计 err={}", e); + return HashMap::new(); + } + }; + + let mut per_cabinet: HashMap> = HashMap::new(); + for (idx, (cabinet_id, _)) in entries.iter().enumerate() { + let field = |i: usize| -> Option<&str> { + results + .get(idx) + .and_then(|vals| vals.get(i)) + .and_then(|v| v.as_deref()) + }; + per_cabinet + .entry(*cabinet_id) + .or_default() + .push(derive_channel_flags(field(0), field(1))); + } + + per_cabinet + .into_iter() + .map(|(cabinet_id, flags)| (cabinet_id, derive_channel_counts(&flags))) + .collect() +} + /// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标) async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) { let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); @@ -459,7 +651,8 @@ pub async fn device_realtime_status( }))) } -/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织 +/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织, +/// 且(配了数据范围时)设备所属项目必须落在范围内 async fn ensure_device_access( state: &AppState, user: &CurrentUser, @@ -467,12 +660,13 @@ async fn ensure_device_access( ) -> Result<(), AppError> { if user.role_level < 2 { if let Some(org_id) = user.organization_id { - let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL) - .bind(imei) - .bind(org_id) - .fetch_one(&state.mysql) - .await - .unwrap_or(false); + let (scope_cond, scope_binds) = user.effective_scope().project_condition("p.id"); + let sql = DEVICE_ACCESS_SQL.replacen("{}", &scope_cond, 1); + let mut query = sqlx::query_scalar::<_, bool>(&sql).bind(imei).bind(org_id); + for bind in &scope_binds { + query = query.bind(bind); + } + let has_access: bool = query.fetch_one(&state.mysql).await.unwrap_or(false); if !has_access { return Err(AppError::Forbidden("无权访问该设备".into())); } @@ -620,18 +814,110 @@ fn parse_hex_byte(raw: Option<&str>) -> Option { u8::from_str_radix(text, 16).ok() } +/// 通道派生结果(协议 §3.8):由 `batt_status` / `pow_status` 两个字节推导出的三态布尔 +/// +/// 这是**全仓唯一权威推导规则**:设备实时详情([`derive_channel_view`])、 +/// 设备列表的充满/故障计数([`derive_channel_counts`])以及仓体展示状态 +/// (`routes::cabinets::get_cabinet_detail` / `routes::h5::get_compartment_detail`) +/// **全部复用它,任何地方都不允许另写一套位判断**。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ChannelFlags { + /// `batt_status` bit1(0x02 正在充电) + pub on: bool, + /// `batt_status` bit0(0x01 充满) + pub full: bool, + /// `batt_status` bit4/5/6(过欠压 / 过流 / 热失控)**或** `pow_status` bit0(电源故障) + pub fault: bool, + /// Redis 里是否存在**可判定**的通道数据(`batt_status` / `pow_status` 至少一个合法) + /// + /// 为 `false` 表示设备离线或该通道从未上报 —— 此时无法判定充满/故障, + /// 调用方必须按「无数据」处理(计数按 0 计 / 展示回落到 DB 占用值),**不得猜测**。 + pub has_data: bool, +} + +impl ChannelFlags { + /// 展示状态名:fault > full > charging > idle(唯一优先级顺序,与旧实现一致) + pub(crate) fn status_name(&self) -> &'static str { + if self.fault { + "fault" + } else if self.full { + "full" + } else if self.on { + "charging" + } else { + "idle" + } + } + + /// 前端状态码:`0 空闲 / 1 充电中 / 2 充满 / 3 故障` + /// + /// 与 `web/src/pages/devices/cabinet.tsx::STATUS_MAP`、 + /// `h5-uniapp/src/pages/device-detail/index.vue::compClass`、 + /// `h5-uniapp/src/pages/compartment-detail/index.vue::statusText` **四处口径必须一致** + /// (**没有"禁用"这一档**:仓体级禁用不存在)。 + pub(crate) fn status_code(&self) -> i8 { + match self.status_name() { + "fault" => 3, + "full" => 2, + "charging" => 1, + _ => 0, + } + } +} + +/// 纯函数:由通道真实字段推导 [`ChannelFlags`](协议 §3.8,位定义与 +/// `software/web/src/utils/deviceStatus.ts` 完全一致) +pub(crate) fn derive_channel_flags(batt_raw: Option<&str>, pow_raw: Option<&str>) -> ChannelFlags { + let batt = parse_hex_byte(batt_raw); + let pow = parse_hex_byte(pow_raw); + + ChannelFlags { + full: batt.is_some_and(|b| b & BATT_FULL_MASK != 0), + on: batt.is_some_and(|b| b & BATT_CHARGING_MASK != 0), + fault: batt.is_some_and(|b| b & BATT_FAULT_MASK != 0) + || pow.is_some_and(|p| p & POW_FAULT_MASK != 0), + has_data: batt.is_some() || pow.is_some(), + } +} + +/// 通道四计数(派生口径),顺序固定:`(idle, charging, full, fault)` +/// +/// ⚠️ 生产中只有 `.2`(充满)/ `.3`(故障)会被写回响应: +/// `idle_count` / `charging_count` 一律用 DB 的**占用**值(`compartments.status` 的 0/1, +/// 那才是这两列的语义),派生出来的 idle/charging 分区**不覆盖**它们。 +/// 保留这两个分区是为了让「全空闲 / 充电中 / 充满 / 故障 / 优先级冲突 / 离线无数据」 +/// 这些边界能在同一个可测纯函数里被完整覆盖(见本文件单测)。 +pub(crate) type ChannelCounts = (i64, i64, i64, i64); + +/// 纯函数:按 [`ChannelFlags`] 的优先级(fault > full > charging > idle)把通道集合分区计数 +/// +/// 无数据(`has_data == false`)的通道按派生 idle 记账 —— 因此「离线 / 无该通道键」时 +/// 充满数与故障数恒为 0(**离线无法判定,按 0 计**)。 +pub(crate) fn derive_channel_counts(channels: &[ChannelFlags]) -> ChannelCounts { + let mut counts: ChannelCounts = (0, 0, 0, 0); + for flags in channels { + match flags.status_code() { + 3 => counts.3 += 1, + 2 => counts.2 += 1, + 1 => counts.1 += 1, + _ => counts.0 += 1, + } + } + counts +} + /// 纯函数:把通道的**真实字段**组装成响应 JSON。 /// -/// **响应字段名与旧实现完全一致**(前端在消费),但取值改为由真实字段推导: +/// **响应字段名与旧实现完全一致**(前端在消费),但取值改为由真实字段推导 +/// (位判断统一走 [`derive_channel_flags`],本函数不再自己算位): /// - `on` = `batt_status` bit1(0x02 正在充电) /// - `full` = `batt_status` bit0(0x01 充满) /// - `fault` = `batt_status` bit4/5/6(过欠压 / 过流 / 热失控)**或** `pow_status` bit0(电源故障) /// - `hex` = `batt_status` + `pow_status`(即通道 18 字符 hex 的前 4 个字符;缺任一字节则为空串) -/// - `status` = `fault` > `full` > `charging` > `idle`(与旧实现的优先级顺序一致) +/// - `status` = `fault` > `full` > `charging` > `idle`(见 [`ChannelFlags::status_name`]) /// - `temp` = 原样透传(单位 **K×100**,由前端换算,见 `web/.../cabinet.tsx`) /// 另外**新增** `batt_status` / `pow_status` / `soc`(‰)/ `voltage`(mV)/ `current`(mA) /// 便于管理端展示真实语义(新增字段不影响既有字段名)。 -/// 位定义与前端 `web/src/utils/deviceStatus.ts` 完全一致(两端口径必须一致)。 fn derive_channel_view( batt_raw: Option<&str>, pow_raw: Option<&str>, @@ -640,23 +926,10 @@ fn derive_channel_view( current: Option, temp: Option, ) -> Value { + // hex 需要原始字节;位判断统一交给 `derive_channel_flags`(重复解析是无副作用的纯计算) let batt = parse_hex_byte(batt_raw); let pow = parse_hex_byte(pow_raw); - - let full = batt.is_some_and(|b| b & BATT_FULL_MASK != 0); - let on = batt.is_some_and(|b| b & BATT_CHARGING_MASK != 0); - let fault = batt.is_some_and(|b| b & BATT_FAULT_MASK != 0) - || pow.is_some_and(|p| p & POW_FAULT_MASK != 0); - - let status = if fault { - "fault" - } else if full { - "full" - } else if on { - "charging" - } else { - "idle" - }; + let flags = derive_channel_flags(batt_raw, pow_raw); // 统一输出小写 2 位 hex(与 `report_worker` 的写入格式一致) let batt_status = batt.map(|b| format!("{:02x}", b)).unwrap_or_default(); @@ -668,10 +941,10 @@ fn derive_channel_view( }; json!({ - "status": status, - "on": on, - "full": full, - "fault": fault, + "status": flags.status_name(), + "on": flags.on, + "full": flags.full, + "fault": flags.fault, "hex": hex, "temp": temp, "batt_status": batt_status, @@ -726,6 +999,9 @@ async fn load_channel_status( #[cfg(test)] mod tests { use super::*; + // 设备列表测试要构造 `CurrentUser`(其 `scope` 字段是 `UserScope`), + // `super::*` 只带进来 `CurrentUser`,`UserScope` 需显式引入 + use crate::middleware::auth::UserScope; /// 交付文档 §5.3 示例 `828255152c01f40143`:有电池 + 正在充电、电源已打开 #[test] @@ -822,6 +1098,115 @@ mod tests { assert_eq!(view["hex"], "8a0a"); } + // ---- ChannelFlags / derive_channel_counts:由通道状态派生「充满数 / 故障数」 ---- + + /// 全空闲:有数据但既没充满也没故障(`batt_status=00`,电源已开)→ 全部记 idle + #[test] + fn test_derive_channel_counts_all_idle_channels_yield_zero_full_fault() { + let channels: Vec = ["00", "80"] + .iter() + .map(|batt| derive_channel_flags(Some(*batt), Some("82"))) + .collect(); + assert_eq!(derive_channel_counts(&channels), (2, 0, 0, 0)); + } + + /// 充电中(`batt_status` bit1)→ 记 charging,不算充满也不算故障 + #[test] + fn test_derive_channel_counts_charging_channels_counted_as_charging() { + let channels = vec![ + derive_channel_flags(Some("82"), Some("82")), + derive_channel_flags(Some("82"), Some("82")), + derive_channel_flags(Some("00"), Some("82")), + ]; + assert_eq!(derive_channel_counts(&channels), (1, 2, 0, 0)); + } + + /// 充满(`batt_status` bit0):即使同时带充电位(0x83)也只记充满 + #[test] + fn test_derive_channel_counts_full_channels_counted_as_full() { + let channels = vec![ + derive_channel_flags(Some("81"), Some("82")), + derive_channel_flags(Some("83"), Some("82")), + ]; + assert_eq!(derive_channel_counts(&channels), (0, 0, 2, 0)); + } + + /// 故障:`batt_status` bit4/5/6 与 `pow_status` bit0 都算故障(两个维度独立) + #[test] + fn test_derive_channel_counts_fault_channels_counted_as_fault() { + let channels = vec![ + derive_channel_flags(Some("10"), Some("00")), + derive_channel_flags(Some("20"), Some("00")), + derive_channel_flags(Some("40"), Some("00")), + derive_channel_flags(Some("00"), Some("01")), + ]; + assert_eq!(derive_channel_counts(&channels), (0, 0, 0, 4)); + } + + /// **优先级冲突**:同时满足「充满」(bit0)与「故障」(bit4)时算故障,不算充满 + #[test] + fn test_derive_channel_counts_fault_wins_over_full_on_conflict() { + // 0x11 = 充满 + 过欠压;0x13 = 充满 + 充电中 + 过欠压;0x41 = 充满 + 热失控 + let channels = vec![ + derive_channel_flags(Some("11"), Some("00")), + derive_channel_flags(Some("13"), Some("00")), + derive_channel_flags(Some("41"), Some("00")), + ]; + let (idle, charging, full, fault) = derive_channel_counts(&channels); + assert_eq!((idle, charging, full, fault), (0, 0, 0, 3)); + } + + /// **离线 / 无数据**:空集合与「字段全缺失」的通道 → 充满数故障数都必须为 0(不猜测) + #[test] + fn test_derive_channel_counts_offline_missing_data_yields_zero_full_fault() { + // 该柜在 Redis 里一个通道键都没有(设备从未上报) + assert_eq!(derive_channel_counts(&[]), (0, 0, 0, 0)); + + // 键存在但字段全缺失(老数据 / 通道刚建立)→ 无数据,同样不得计成充满或故障 + let no_data = vec![ + derive_channel_flags(None, None), + derive_channel_flags(None, None), + ]; + let (idle, charging, full, fault) = derive_channel_counts(&no_data); + assert_eq!((idle, charging, full, fault), (2, 0, 0, 0)); + + // 混合:一半有数据(一个充满 + 一个故障)、一半无数据 → 只统计有数据的那两个 + let mixed = vec![ + derive_channel_flags(Some("81"), Some("82")), + derive_channel_flags(Some("10"), Some("00")), + derive_channel_flags(None, None), + derive_channel_flags(None, None), + ]; + let (idle, charging, full, fault) = derive_channel_counts(&mixed); + assert_eq!((idle, charging, full, fault), (2, 0, 1, 1)); + } + + /// `has_data` / `status_code`:与前端 `0空闲/1充电中/2充满/3故障` 口径一致,无"禁用"档 + #[test] + fn test_channel_flags_has_data_and_status_code_match_frontend_mapping() { + // 无数据(字段缺失)→ has_data=false,状态码回落 0(调用方据此改用 DB 占用值) + let none = derive_channel_flags(None, None); + assert!(!none.has_data); + assert_eq!(none.status_code(), 0); + + // 只有电源状态、没有电池状态 → 数据存在,按电源维度判定(电源故障=3) + let pow_only = derive_channel_flags(None, Some("01")); + assert!(pow_only.has_data); + assert_eq!(pow_only.status_code(), 3); + + assert_eq!(derive_channel_flags(Some("00"), Some("82")).status_code(), 0); + assert_eq!(derive_channel_flags(Some("82"), Some("82")).status_code(), 1); + assert_eq!(derive_channel_flags(Some("81"), Some("02")).status_code(), 2); + assert_eq!(derive_channel_flags(Some("10"), Some("00")).status_code(), 3); + + // 非法 hex 与空串都按「无数据」处理(不 panic、不猜成充满/故障) + for bad in ["", " ", "zz", "123"] { + let flags = derive_channel_flags(Some(bad), Some(bad)); + assert!(!flags.has_data, "非法 hex {bad:?} 必须按无数据处理"); + assert_eq!(flags.status_code(), 0); + } + } + /// hex 解析:长度超 2 / 非 hex / 空白 → `None` #[test] fn test_parse_hex_byte_rejects_malformed_values() { @@ -834,8 +1219,36 @@ mod tests { assert_eq!(parse_hex_byte(Some(" 82 ")), Some(0x82)); } - // ---- parse_channel_key:通道级 key 分组(板号 1–15 / 通道号 1–8)------- + /// 设备列表 pipeline 的柜级 hex 位域取值(协议 §3.8): + /// `"00"`(全正常)必须原样保留,缺失/空串/纯空白必须为 `None`(响应 `null`)。 + #[test] + fn test_pick_non_empty_hex_keeps_00_but_drops_missing() { + assert_eq!( + pick_non_empty_hex(Some(&Some("00".to_string()))).as_deref(), + Some("00"), + "\"00\" 是「全部正常」的有效值,不能当成无数据" + ); + assert_eq!( + pick_non_empty_hex(Some(&Some("10".to_string()))).as_deref(), + Some("10"), + "柜门打开位必须原样透出" + ); + assert_eq!(pick_non_empty_hex(Some(&Some(String::new()))), None); + assert_eq!(pick_non_empty_hex(Some(&Some(" ".to_string()))), None); + assert_eq!(pick_non_empty_hex(Some(&None)), None); + assert_eq!(pick_non_empty_hex(None), None); + } + /// 设备列表 pipeline 每设备字段数必须与命令顺序/解析下标同步 + #[test] + fn test_device_list_fields_in_sync() { + assert_eq!( + DEVICE_LIST_FIELDS, 6, + "online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2" + ); + } + + // ---- parse_channel_key:通道级 key 分组(板号 1–15 / 通道号 1–8)------- /// 正常通道 key → (板号, 通道号) #[test] fn test_parse_channel_key_accepts_real_channel_keys() { @@ -881,4 +1294,128 @@ mod tests { None ); } + + // ----------------------------------------------------------------------- + // 设备列表的数据范围(user_scopes)叠加 + // ----------------------------------------------------------------------- + + /// 构造一个客户侧用户(role=1,属组织 42),scope 由调用方给定 + fn make_user(scope: UserScope) -> CurrentUser { + CurrentUser { + user_id: 9, + phone: "13800138000".into(), + permissions: std::collections::HashSet::new(), + role_level: 1, + organization_id: Some(42), + scope, + } + } + + /// **未配置 scope = 不限制**:设备列表 WHERE 与原实现逐字一致(向后兼容) + #[test] + fn test_build_filter_where_without_scope_is_unchanged() { + let params = CabinetFilterParams { + organization_id: None, + project_id: None, + page: None, + page_size: None, + bound: None, + }; + let (clause, binds) = build_filter_where(¶ms, &make_user(UserScope::default())); + assert_eq!(clause, "JOIN projects p ON c.project_id = p.id \n WHERE p.organization_id = ?"); + assert_eq!(binds, vec!["42"]); + } + + /// **配置了项目范围**:组织条件之后必须再叠加 `c.project_id IN (...)`(取交集) + #[test] + fn test_build_filter_where_with_scope_appends_project_narrowing() { + let params = CabinetFilterParams { + organization_id: None, + project_id: None, + page: None, + page_size: None, + bound: None, + }; + let user = make_user(UserScope { project_ids: vec![7, 9] }); + let (clause, binds) = build_filter_where(¶ms, &user); + + assert!(clause.contains("WHERE p.organization_id = ?"), "组织隔离不能丢:{}", clause); + assert!(clause.contains("AND c.project_id IN (?,?)"), "数据范围必须叠加:{}", clause); + assert_eq!(binds, vec!["42", "7", "9"], "绑定顺序必须与占位符顺序一致"); + } + + /// 显式指定了范围外项目 / 其他组织时,范围条件仍然叠加 ⇒ 结果必然为空集(不泄露) + #[test] + fn test_build_filter_where_scope_applies_to_explicit_project_and_org_params() { + let user = make_user(UserScope { project_ids: vec![7] }); + + let by_project = CabinetFilterParams { + organization_id: None, + project_id: Some(999), + page: None, + page_size: None, + bound: None, + }; + let (clause, binds) = build_filter_where(&by_project, &user); + assert_eq!(clause, "WHERE c.project_id = ? AND c.project_id IN (?)"); + assert_eq!(binds, vec!["999", "7"]); + + let by_org = CabinetFilterParams { + organization_id: Some(43), + project_id: None, + page: None, + page_size: None, + bound: None, + }; + let (clause, binds) = build_filter_where(&by_org, &user); + assert!(clause.contains("AND c.project_id IN (?)"), "组织参数分支也要叠加范围:{}", clause); + assert_eq!(binds, vec!["43", "43", "7"]); + } + + /// 已绑定/未绑定过滤与数据范围可以共存,且绑定值顺序正确 + #[test] + fn test_build_filter_where_scope_combines_with_bound_filter() { + let params = CabinetFilterParams { + organization_id: None, + project_id: None, + page: None, + page_size: None, + bound: Some("1".into()), + }; + let user = make_user(UserScope { project_ids: vec![7] }); + let (clause, binds) = build_filter_where(¶ms, &user); + assert!(clause.ends_with("AND c.project_id IS NOT NULL AND c.project_id IN (?)"), "{}", clause); + assert_eq!(binds, vec!["42", "7"]); + } + + /// 总管理员(role_level>=2)不受数据范围收窄(豁免) + #[test] + fn test_build_filter_where_super_admin_ignores_scope() { + let params = CabinetFilterParams { + organization_id: None, + project_id: None, + page: None, + page_size: None, + bound: None, + }; + let mut user = make_user(UserScope { project_ids: vec![7] }); + user.role_level = 2; + user.organization_id = None; + let (clause, binds) = build_filter_where(¶ms, &user); + assert!(clause.is_empty(), "总管理员无任何过滤:{}", clause); + assert!(binds.is_empty()); + } + + /// 设备归属校验 SQL:未配置范围时不收窄,配置后必须叠加项目收窄 + #[test] + fn test_device_access_sql_appends_scope_only_when_configured() { + let plain = DEVICE_ACCESS_SQL.replacen("{}", "", 1); + assert!(plain.contains("p.organization_id = ?)")); + assert!(!plain.contains("project_id IN")); + + let (cond, binds) = UserScope { project_ids: vec![7, 9] }.project_condition("p.id"); + let narrowed = DEVICE_ACCESS_SQL.replacen("{}", &cond, 1); + assert!(narrowed.contains("p.id IN (?,?)"), "{}", narrowed); + assert_eq!(binds, vec!["7", "9"]); + } } diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index b7be46f..0805635 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -16,6 +16,7 @@ use super::organizations::{ }; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; +use super::cabinet_tree::derive_channel_flags; use super::scope_guard; /// 基础柜子行(只包含数据库字段,用于简单查询) @@ -55,6 +56,9 @@ impl From for super::organizations::CabinetRow { rssi: None, pow_fail_dc: None, pow_fail_ac: None, + // 列表(按项目查柜子)不读 Redis,柜级位域如实为 null + status1: None, + status2: None, is_online: None, org_disabled: None, project_address: None, @@ -69,7 +73,8 @@ impl From for super::organizations::CabinetRow { /// 验证用户是否有权访问指定项目 /// /// 总管理员(role_level >= 2)可访问所有项目; -/// 企业管理员只能访问本组织下的项目。 +/// 企业管理员只能访问本组织下的项目,**且该项目必须在其数据范围内**(未配置范围 = 不限制)。 +/// 这是「按项目查设备列表」与「设备详情」共用的唯一入口 —— 一处收窄,两处生效。 async fn check_project_access( user: &CurrentUser, pool: &MySqlPool, @@ -81,14 +86,17 @@ async fn check_project_access( } if let Some(org_id) = user.organization_id { - // 验证项目是否属于用户的组织 - let project: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM projects WHERE id = ? AND organization_id = ?", - ) - .bind(project_id) - .bind(org_id) - .fetch_optional(pool) - .await?; + // 组织归属 + 数据范围(项目级)一次查完,避免两次往返 + let (scope_cond, scope_binds) = user.effective_scope().project_condition("id"); + let sql = format!( + "SELECT id FROM projects WHERE id = ? AND organization_id = ?{}", + scope_cond + ); + let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(project_id).bind(org_id); + for bind in &scope_binds { + query = query.bind(bind); + } + let project: Option<(i64,)> = query.fetch_optional(pool).await?; if project.is_none() { return Err(AppError::Forbidden("无权访问该项目".into())); @@ -114,8 +122,14 @@ pub async fn list_cabinets( check_project_access(&user, &state.mysql, project_id).await?; let db = &state.mysql; + // ⚠️ 字段必须与 `CabinetBasicRow` 一一对应(含 `latitude`/`longitude`): + // 少了经纬度这一列,`FromRow` 解码会直接报 `no column found for name: latitude` + // 并以 `DATABASE_ERROR` 500 掉整个列表接口(历史缺陷,本次随数据范围改造一并修掉)。 + // 经纬度与详情接口同口径:`CAST(... AS CHAR)`,规避 sqlx 对 DECIMAL 的解码问题。 let sql_rows = sqlx::query_as::<_, CabinetBasicRow>( - r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode + r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, + CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, + status, work_mode FROM cabinets WHERE project_id = ? ORDER BY id"#, ) .bind(project_id) @@ -553,6 +567,12 @@ pub async fn get_cabinet_detail( check_project_access(&user, db, pid).await?; } + // 数据范围兜底:**未划拨到项目的柜子**(`project_id` 为空)走不到上面的项目校验, + // 配了范围的用户一律不可见(`allows_project(None)` 在有限制时为 false)。 + if !user.effective_scope().allows_project(cabinet.project_id) { + return Err(AppError::Forbidden("无权访问该设备".into())); + } + // 仓控板 + 仓体(含实时字段占位),并收集通道 Redis key 所需的映射 let (mut cabin_boards, comp_ids, comp_to_board) = load_boards_with_compartments(db, id).await?; @@ -592,6 +612,11 @@ pub async fn get_cabinet_detail( // 柜温:Redis `device:{imei}.temp`,语义 K×100(与柜级 temp 口径一致); // 取不到为 null(前端显示 `--°C`),不落 0 "temp": realtime.temp, + // **柜级**状态位(协议 §3.8):Redis `device:{imei}` 的 `cabinet_status1/2` + // 原样透出 2 位小写 hex 字符串(**不在后端解析成布尔**,前端两端有统一纯函数); + // 取不到为 null —— 与 `"00"`("全部正常"的有效值)严格区分,绝不瞎填 "00" + "status1": realtime.status1, + "status2": realtime.status2, "address": cabinet.address, "latitude": cabinet.latitude, "longitude": cabinet.longitude, @@ -616,6 +641,21 @@ struct CabinetRealtime { pow_fail_ac: Option, /// 柜温,平台单位 K×100(Redis `device:{imei}` 的 `temp`) temp: Option, + /// **柜级**柜体状态1(协议 §3.8,Redis `device:{imei}` 的 `cabinet_status1`) + /// + /// 2 位小写 hex 字符串**原样透出**(如 `"10"` = 柜门打开、`"82"` = 喷淋 + 烟雾), + /// **不在 Rust 里解析成布尔** —— 它是位域,前端两端有统一的 hex 解析纯函数 + /// (`web/src/utils/deviceStatus.ts` / `h5-uniapp/src/utils/deviceStatus.ts` 的 + /// `parseCabinetStatus1`),与既有 `batt_status` / `pow_status` 的做法完全一致。 + /// + /// ⚠️ `None`(响应 `null`)= **无数据**,与 `Some("00")`(**全正常**的有效值)语义不同, + /// 绝不能把缺失落成 `"00"`。 + status1: Option, + /// **柜级**柜体状态2(协议 §3.8,Redis `device:{imei}` 的 `cabinet_status2`) + /// + /// bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现不判定);bit2–7 为保留位。 + /// 同 [`Self::status1`]:原样透出 hex,`None` = 无数据。 + status2: Option, /// 仓体 id -> (SOC, 电池ID) soc_map: std::collections::HashMap, Option)>, /// 仓体 id -> 通道级实时状态 @@ -640,10 +680,17 @@ struct ChannelRealtime { current: Option, } -/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数:rssi / pow_fail_dc / pow_fail_ac / temp。 -/// 与 [`fetch_cabinet_realtime`] 下发的命令顺序、[`parse_cabinet_realtime`] 的下标解析 -/// **三者必须同步修改**,故抽成常量。 -const CABINET_REALTIME_FIELDS: usize = 4; +/// pipeline 中柜级(`device:{imei}`)HGET 的字段个数: +/// `rssi` / `pow_fail_dc` / `pow_fail_ac` / `temp` / `cabinet_status1` / `cabinet_status2`。 +/// +/// ⚠️ **命令顺序与下标一一对应**:本常量同时是 [`fetch_cabinet_realtime`] 下发 `HGET` 的 +/// 顺序(下标 0..[`CABINET_REALTIME_FIELDS`])与 [`parse_cabinet_realtime`] 的柜级下标解析依据, +/// 还决定了 SOC 段(`CABINET_REALTIME_FIELDS + i`)与通道段(`+ comp_ids.len()`)的起始偏移。 +/// **新增柜级字段必须同步改这一处,且只能追加在末尾**(改动即破坏下标对齐)。 +const CABINET_REALTIME_FIELDS: usize = 6; +/// 柜级 hex 位域字段在 pipeline 结果中的下标(协议 §3.8,**柜级**:`cabinet.status1` / `status2`) +const CABINET_STATUS1_IDX: usize = 4; +const CABINET_STATUS2_IDX: usize = 5; /// pipeline 中每个通道 HGET 的字段个数:temp / batt_status / pow_status / voltage / current const CHANNEL_REALTIME_FIELDS: usize = 5; @@ -713,12 +760,16 @@ async fn load_boards_with_compartments( /// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态 /// -/// 为什么放一起:详情接口要求低延迟,柜级 4 个字段 + 每通道 5 个字段共用一次网络往返。 +/// 为什么放一起:详情接口要求低延迟,柜级 6 个字段 + 每通道 5 个字段共用一次网络往返。 /// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 /// /// 柜级第 4 个字段 `temp`(柜温,K×100)与通道级 `voltage`/`current` 是**补取**字段: /// 设备上报(`report_worker::write_device_state` / `write_channel_state`)早就写进了 Redis, /// 只是接口没取,导致 PC 端柜温恒为 `--°C`、电压电流恒为 `-mV -mA`。 +/// +/// 柜级第 5、6 个字段(`cabinet_status1` / `cabinet_status2`,协议 §3.8)同样是**补取**: +/// `report_worker::handle_status_post` 一直在写,但此前全仓无人读 —— 本次透出为 +/// 响应根字段 `status1` / `status2`(**柜级**语义,前端按层级展示在"柜子"层面)。 async fn fetch_cabinet_realtime( redis_url: &str, imei: &str, @@ -742,11 +793,16 @@ async fn fetch_cabinet_realtime( }; let key = format!("device:{}", imei); + // ⚠️ 命令顺序必须与 CABINET_REALTIME_FIELDS / CABINET_STATUS*_IDX 及 + // parse_cabinet_realtime 的下标解析**完全一致**(新增柜级字段只能追加在末尾) let mut pipe = redis::pipe(); pipe.cmd("HGET").arg(&key).arg("rssi") .cmd("HGET").arg(&key).arg("pow_fail_dc") .cmd("HGET").arg(&key).arg("pow_fail_ac") - .cmd("HGET").arg(&key).arg("temp"); + .cmd("HGET").arg(&key).arg("temp") + // 柜级位域(协议 §3.8):设备上报时由 report_worker 写入,原样透出给前端解析 + .cmd("HGET").arg(&key).arg("cabinet_status1") + .cmd("HGET").arg(&key).arg("cabinet_status2"); for cid in comp_ids { pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); } @@ -789,12 +845,20 @@ fn parse_cabinet_realtime( fn pick_i64(vals: &[Option], i: usize) -> Option { pick(vals, i).and_then(|v| v.trim().parse::().ok()) } + /// 取出第 i 项的非空字符串:`None` 或**纯空白**都视为"无数据"(Redis 里不该有空值) + fn pick_non_empty(vals: &[Option], i: usize) -> Option { + pick(vals, i).filter(|v| !v.trim().is_empty()) + } let mut rt = CabinetRealtime { rssi: pick(vals, 0).and_then(|v| v.parse().ok()), pow_fail_dc: pick(vals, 1).map(|v| v == "1"), pow_fail_ac: pick(vals, 2).map(|v| v == "1"), temp: pick_i64(vals, 3), + // 柜级位域:**原样透出 hex**(不解析成布尔 —— 位域不是单标志); + // 缺数据时保持 None → 响应 null,**绝不落成 "00"**("00" 是"全部正常"的有效值) + status1: pick_non_empty(vals, CABINET_STATUS1_IDX), + status2: pick_non_empty(vals, CABINET_STATUS2_IDX), ..Default::default() }; @@ -828,6 +892,10 @@ fn parse_cabinet_realtime( } /// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串,也绝不落 0 +/// +/// 另外把每个仓体的 `status` 从「DB 占用值(0/1)」改写成**派生展示值** +/// (有通道数据时 `0空闲 / 1充电中 / 2充满 / 3故障`;无数据时保持 DB 值), +/// 因为 `compartments.status` 只表达占用,DB 里永远看不到"充满/故障"。 fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { for board in cabin_boards.iter_mut() { let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else { @@ -846,6 +914,16 @@ fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { } } if let Some(ch) = rt.ch_map.get(&cid) { + // 展示状态改为**派生值**:有通道实时数据时按 + // fault > full > charging > idle → `3故障 / 2充满 / 1充电中 / 0空闲` + // (与 `cabinet_tree::derive_channel_flags` 唯一权威规则同源, + // 前端 `STATUS_MAP` 只按 0/1/2/3 渲染,**没有"禁用"档**); + // 拿不到数据时**保留上面写入的 DB 占用值(0/1)** —— 离线无法判定,不猜测。 + let flags = + derive_channel_flags(ch.batt_status.as_deref(), ch.pow_status.as_deref()); + if flags.has_data { + comp["status"] = json!(flags.status_code()); + } if let Some(t) = ch.temp { comp["temp"] = json!(t); } @@ -914,7 +992,13 @@ pub struct CompartmentOption { pub channel_id: i8, } +/// 柜子下拉列表的固定列清单(列表/详情共用同一组字段,避免两处漂移) +const CABINET_OPTION_COLUMNS: &str = + "id, name, abstract_id, imei, iccid, address, status"; + /// GET /api/cabinets/options — 柜子下拉列表 +/// +/// **数据范围**:配了项目范围的用户只看到范围内项目的柜子(未配置 = 不限制)。 pub async fn list_cabinet_options( user: CurrentUser, State(state): State, @@ -922,20 +1006,29 @@ pub async fn list_cabinet_options( ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; - let rows = if let Some(pid) = params.project_id { - sqlx::query_as::<_, CabinetOption>( - "SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets WHERE project_id = ? ORDER BY id" - ) - .bind(pid) - .fetch_all(&state.mysql) - .await? - } else { - sqlx::query_as::<_, CabinetOption>( - "SELECT id, name, abstract_id, imei, iccid, address, status FROM cabinets ORDER BY id" - ) - .fetch_all(&state.mysql) - .await? - }; + // 动态拼 WHERE:项目筛选在前、数据范围收窄在后,绑定顺序与占位符顺序严格一致 + let (scope_cond, scope_binds) = user.effective_scope().project_condition("project_id"); + let mut where_clause = String::new(); + let mut binds: Vec = Vec::new(); + if let Some(pid) = params.project_id { + where_clause.push_str("project_id = ?"); + binds.push(pid.to_string()); + } + auth::append_condition(&mut where_clause, &scope_cond); + binds.extend(scope_binds); + + let mut sql = format!("SELECT {} FROM cabinets", CABINET_OPTION_COLUMNS); + if !where_clause.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&where_clause); + } + sql.push_str(" ORDER BY id"); + + let mut query = sqlx::query_as::<_, CabinetOption>(&sql); + for bind in &binds { + query = query.bind(bind); + } + let rows = query.fetch_all(&state.mysql).await?; Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } @@ -984,18 +1077,20 @@ pub async fn list_compartment_options( mod tests { use super::*; - /// pipeline 结果解析:柜级 4 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局, + /// pipeline 结果解析:柜级 6 项 + 每仓体 1 项(SOC) + 每通道 5 项的下标布局, /// 并确认电压/电流/柜温被正确取出(回归 ③:数据在 Redis 但接口不取) #[test] fn test_parse_cabinet_realtime_reads_voltage_current_and_temp() { let comp_ids = vec![11_i64, 12_i64]; let comp_to_board = vec![(11_i64, (1_i8, 1_i8)), (12_i64, (1_i8, 2_i8))]; let vals: Vec> = vec![ - // 柜级:rssi / pow_fail_dc / pow_fail_ac / temp + // 柜级:rssi / pow_fail_dc / pow_fail_ac / temp / cabinet_status1 / cabinet_status2 Some("85".into()), Some("1".into()), Some("0".into()), Some("29815".into()), + Some("10".into()), + Some("01".into()), // 每仓体 1 项(compartment:status:{id},该 key 无写入方 → 通常为 None) None, None, @@ -1019,6 +1114,8 @@ mod tests { assert_eq!(rt.pow_fail_dc, Some(true)); assert_eq!(rt.pow_fail_ac, Some(false)); assert_eq!(rt.temp, Some(29815), "柜温应被取出"); + assert_eq!(rt.status1.as_deref(), Some("10"), "柜级 status1 应原样透出 hex"); + assert_eq!(rt.status2.as_deref(), Some("01"), "柜级 status2 应原样透出 hex"); let ch11 = rt.ch_map.get(&11).expect("通道 11 应有实时状态"); assert_eq!(ch11.voltage, Some(53200), "电压应被取出"); @@ -1033,18 +1130,101 @@ mod tests { assert_eq!(ch12.voltage, Some(48000)); } + /// 柜级位域解析口径(协议 §3.8 / §4.2): + /// `"00"` 是"全部正常"的**有效值**,`"10"` 是柜门打开,`"82"` 是喷淋 + 烟雾, + /// `"01"`(status2) 是声光报警 —— 一律**原样透出**,不作任何改写、不解析成布尔。 + #[test] + fn test_parse_cabinet_realtime_cabinet_status_bits_raw_hex() { + let comp_ids: Vec = vec![]; + let comp_to_board: Vec<(i64, (i8, i8))> = vec![]; + + // "00" = 全 0:全正常(**不是**"无数据") + let vals = vec![ + None, + None, + None, + None, + Some("00".into()), + Some("00".into()), + ]; + let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); + assert_eq!(rt.status1.as_deref(), Some("00"), "全正常必须是 Some(\"00\") 而不是 None"); + assert_eq!(rt.status2.as_deref(), Some("00")); + + // "82" = bit7 喷淋 + bit1 烟雾;"10" = 柜门打开 + let vals = vec![ + None, + None, + None, + None, + Some("82".into()), + Some("10".into()), + ]; + let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); + assert_eq!(rt.status1.as_deref(), Some("82"), "喷淋+烟雾应原样透出"); + assert_eq!(rt.status2.as_deref(), Some("10"), "status2 也必须原样透出(不在后端做位判定)"); + } + + /// 柜级位域**取不到**时必须返回 `None`(响应 `null`)—— 绝不瞎填 `"00"`: + /// `"00"` 是"全部正常"的有效值,与"没数据"必须区分得开。 + #[test] + fn test_parse_cabinet_realtime_cabinet_status_missing_is_null_not_00() { + let comp_ids: Vec = vec![]; + let comp_to_board: Vec<(i64, (i8, i8))> = vec![]; + + // HGET 未命中(None)与空串/纯空白都算"无数据" + for raw in [None, Some(String::new()), Some(" ".into())] { + let vals = vec![ + Some("85".into()), + None, + None, + None, + raw.clone(), + raw, + ]; + let rt = parse_cabinet_realtime(&vals, &comp_ids, &comp_to_board); + assert_eq!(rt.status1, None, "缺失/空值必须为 None,不能落成 \"00\""); + assert_eq!(rt.status2, None, "缺失/空值必须为 None,不能落成 \"00\""); + } + + // 长度不足(Redis 里只有柜级前 4 项)同样安全降级为 None,不能 panic + let short = vec![Some("85".into()), None, None, Some("29815".into())]; + let rt = parse_cabinet_realtime(&short, &comp_ids, &comp_to_board); + assert_eq!(rt.status1, None); + assert_eq!(rt.status2, None); + + // 空切片(Redis 整体不可用)也不能 panic + let rt = parse_cabinet_realtime(&[], &comp_ids, &comp_to_board); + assert_eq!(rt.status1, None); + assert_eq!(rt.status2, None); + } + + /// 柜级下标常量与 [`CABINET_REALTIME_FIELDS`] 必须同步 + /// (命令顺序与下标一一对应;新增柜级字段只能追加在末尾) + #[test] + fn test_cabinet_realtime_field_indices_are_in_sync() { + assert_eq!( + CABINET_REALTIME_FIELDS, 6, + "柜级 HGET 字段数变了:必须同步 fetch_cabinet_realtime 的命令顺序与本文件的下标解析" + ); + assert_eq!(CABINET_STATUS1_IDX, CABINET_REALTIME_FIELDS - 2); + assert_eq!(CABINET_STATUS2_IDX, CABINET_REALTIME_FIELDS - 1); + } + /// 字段缺失/非数字时必须保持 None,绝不落 0("无数据"不能伪装成 0) #[test] fn test_parse_cabinet_realtime_missing_fields_stay_none() { let comp_ids = vec![7_i64]; let comp_to_board = vec![(7_i64, (1_i8, 1_i8))]; - // 柜级 4 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 + // 柜级 6 项 + SOC 1 项 + 通道 5 项,其中电压是非数字垃圾值、电流缺失 let vals: Vec> = vec![ None, None, None, None, None, + None, + None, Some("29815".into()), Some("82".into()), Some("03".into()), diff --git a/software/api-server/src/routes/charge_orders.rs b/software/api-server/src/routes/charge_orders.rs index 61fc4e2..5cc73be 100644 --- a/software/api-server/src/routes/charge_orders.rs +++ b/software/api-server/src/routes/charge_orders.rs @@ -83,6 +83,39 @@ struct ChargeOrderRow { created_at: Option, } +/// 组织隔离 + 数据范围(项目级)条件片段,均落在 `charge_orders.cabinet_id` 上 +/// +/// `alias` = `charge_orders` 在该查询里的别名(形如 `"co."`,COUNT 查询无别名时传 `""`)。 +/// +/// - 总管理员:两个条件都为空(豁免); +/// - 无组织的客户侧账号:` AND 1=0`(空集,**绝不退化成"全部"**); +/// - 配了数据范围的用户:再叠加项目级收窄(未配置 = 不限制)。 +fn order_scope_conditions(user: &CurrentUser, alias: &str) -> (String, Vec) { + if user.role_level >= 2 { + return (String::new(), Vec::new()); + } + + let cabinet_col = format!("{}cabinet_id", alias); + let mut cond = String::new(); + let mut binds: Vec = Vec::new(); + + if let Some(org_id) = user.organization_id { + cond.push_str(&format!( + " AND {} IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", + cabinet_col + )); + binds.push(org_id.to_string()); + } else { + cond.push_str(" AND 1=0"); + } + + let (scope_cond, scope_binds) = user.effective_scope().cabinet_condition(&cabinet_col); + cond.push_str(&scope_cond); + binds.extend(scope_binds); + + (cond, binds) +} + /// GET /api/charge-orders — 充电订单列表 pub async fn list_charge_orders( user: CurrentUser, @@ -126,10 +159,18 @@ pub async fn list_charge_orders( count_query.push_str(" AND end_time <= ?"); } + // 组织隔离 + 数据范围:必须追加在 LIMIT/OFFSET 之前,绑定值也必须排在分页参数之前 + let (list_scope_cond, scope_binds) = order_scope_conditions(&user, "co."); + let (count_scope_cond, _) = order_scope_conditions(&user, ""); + query.push_str(&list_scope_cond); + count_query.push_str(&count_scope_cond); + query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); - let total: i64 = build_count_query(count_query, ¶ms, &state.mysql).await?; - let rows: Vec = build_list_query(query, params, page_size, offset, &state.mysql).await?; + let total: i64 = + build_count_query(count_query, ¶ms, &scope_binds, &state.mysql).await?; + let rows: Vec = + build_list_query(query, params, &scope_binds, page_size, offset, &state.mysql).await?; let items: Vec = rows.into_iter().map(|r| ChargeOrderItem { id: r.id, @@ -165,9 +206,11 @@ pub async fn list_charge_orders( /// /// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致: /// cabinet_id → compartment_id → battery_id → status → start_time → end_time +/// → **组织隔离/数据范围**(`scope_binds`) async fn build_count_query( query: String, params: &ChargeOrderQuery, + scope_binds: &[String], mysql: &sqlx::MySqlPool, ) -> Result { let mut q = sqlx::query_scalar::<_, i64>(&query); @@ -190,16 +233,21 @@ async fn build_count_query( if let Some(ref et) = params.end_time { q = q.bind(et); } + for bind in scope_binds { + q = q.bind(bind); + } q.fetch_one(mysql).await } /// 执行列表查询并返回订单行。 /// /// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致: -/// cabinet_id → compartment_id → battery_id → status → start_time → end_time → LIMIT → OFFSET +/// cabinet_id → compartment_id → battery_id → status → start_time → end_time +/// → **组织隔离/数据范围** → LIMIT → OFFSET async fn build_list_query( query: String, params: ChargeOrderQuery, + scope_binds: &[String], page_size: i64, offset: i64, mysql: &sqlx::MySqlPool, @@ -224,7 +272,75 @@ async fn build_list_query( if let Some(ref et) = params.end_time { q = q.bind(et); } + for bind in scope_binds { + q = q.bind(bind); + } // 分页参数始终存在,放最后 q = q.bind(page_size).bind(offset); q.fetch_all(mysql).await } + +// --------------------------------------------------------------------------- +// 单元测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::middleware::auth::UserScope; + use std::collections::HashSet; + + fn make_user(role_level: i32, org: Option, scope: UserScope) -> CurrentUser { + CurrentUser { + user_id: 1, + phone: "13800138000".into(), + permissions: HashSet::new(), + role_level, + organization_id: org, + scope, + } + } + + /// 非总管理员必须有组织隔离条件;无组织 ⇒ `1=0` 空集(绝不退化成"全部") + #[test] + fn test_order_scope_conditions_org_isolation() { + let (cond, binds) = order_scope_conditions(&make_user(1, Some(42), UserScope::default()), "co."); + assert!(cond.contains("co.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)"), "{}", cond); + assert_eq!(binds, vec!["42"]); + + let (cond, binds) = order_scope_conditions(&make_user(0, None, UserScope::default()), "co."); + assert_eq!(cond, " AND 1=0"); + assert!(binds.is_empty()); + } + + /// 总管理员豁免两道过滤 + #[test] + fn test_order_scope_conditions_super_admin_exempt() { + let user = make_user(2, None, UserScope { project_ids: vec![7] }); + let (cond, binds) = order_scope_conditions(&user, "co."); + assert!(cond.is_empty(), "总管理员不得有任何过滤:{}", cond); + assert!(binds.is_empty()); + } + + /// 配了数据范围 ⇒ 组织条件之后叠加项目收窄;COUNT 查询用无别名列名 + #[test] + fn test_order_scope_conditions_applies_scope() { + let user = make_user(1, Some(42), UserScope { project_ids: vec![7, 9] }); + let (cond, binds) = order_scope_conditions(&user, "co."); + // 项目级收窄必须**实打实**出现在 SQL 里(范围外项目的订单不得出现) + assert!( + cond.contains("co.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (?,?))"), + "订单列表必须按项目范围收窄:{}", + cond + ); + assert_eq!(binds, vec!["42", "7", "9"]); + + let (cond, _) = order_scope_conditions(&user, ""); + assert!(cond.contains("cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)")); + assert!( + cond.contains("cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (?,?))"), + "COUNT 查询必须无别名:{}", + cond + ); + } +} diff --git a/software/api-server/src/routes/charge_records.rs b/software/api-server/src/routes/charge_records.rs index d9081a0..a20540a 100644 --- a/software/api-server/src/routes/charge_records.rs +++ b/software/api-server/src/routes/charge_records.rs @@ -155,17 +155,18 @@ async fn fetch_charge_rows( query.fetch_all(pool).await } -/// 追加组织数据隔离到 WHERE 子句与绑定值 +/// 追加「组织隔离 + 数据范围」到 WHERE 子句与绑定值 +/// +/// 两道闸门取交集:组织隔离(`cr.cabinet_id → cabinets.project_id → projects.organization_id`) +/// 之后,再按 `CurrentUser::scope` 收窄到有效项目(未配置范围时该片段为空 ⇒ 行为不变)。 fn append_org_scope(user: &CurrentUser, where_clause: &mut String, bind_values: &mut Vec) { let (org_cond, org_binds) = auth::org_condition(user, "cr"); - if !org_cond.is_empty() { - if where_clause.is_empty() { - *where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND ")); - } else { - where_clause.push_str(&org_cond); - } - bind_values.extend(org_binds); - } + auth::append_where(where_clause, &org_cond); + bind_values.extend(org_binds); + + let (scope_cond, scope_binds) = user.effective_scope().cabinet_condition("cr.cabinet_id"); + auth::append_where(where_clause, &scope_cond); + bind_values.extend(scope_binds); } /// 统计符合条件的充电记录总数(JOIN 链路需与数据查询保持一致) @@ -267,13 +268,16 @@ pub async fn export_charge_records( let user_id = user.user_id; let file_name = generate_export_filename(¶ms); - // 序列化查询参数(含组织过滤信息) + // 序列化查询参数(含组织过滤信息 + 数据范围) let query_json = serde_json::to_string(&json!({ "cabinet_id": params.cabinet_id, "start_time": params.start_time, "end_time": params.end_time, "org_id": user.organization_id, "role_level": user.role_level, + // 数据范围(项目级白名单):导出在后台任务里执行,必须把范围一并带过去, + // 否则导出会绕过 scope 把全组织数据导出(越权导出) + "scope_project_ids": user.effective_scope().project_ids, })) .unwrap_or_default(); @@ -344,7 +348,7 @@ async fn load_export_task( Ok((file_name, params)) } -/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤) +/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤 + 数据范围) fn build_export_where(params: &Value) -> (String, Vec) { let (mut where_clause, mut bind_values) = build_where_clause( params.get("cabinet_id").and_then(|v| v.as_i64()), @@ -367,6 +371,19 @@ fn build_export_where(params: &Value) -> (String, Vec) { } } + // 数据范围(非总管理员):与在线查询同一套收窄口径 + if role_level < 2 { + let project_ids: Vec = params + .get("scope_project_ids") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect()) + .unwrap_or_default(); + let (scope_cond, scope_binds) = + auth::UserScope { project_ids }.cabinet_condition("cr.cabinet_id"); + auth::append_where(&mut where_clause, &scope_cond); + bind_values.extend(scope_binds); + } + (where_clause, bind_values) } @@ -465,3 +482,51 @@ async fn generate_charge_records_excel( tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path); Ok(()) } + +// --------------------------------------------------------------------------- +// 单元测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// 导出 WHERE:数据范围(项目白名单)必须与组织过滤一起落到后台导出任务里 + #[test] + fn test_build_export_where_applies_scope_project_ids() { + // 未配置范围(空数组 / 字段缺失)⇒ 只有组织条件,行为与今天一致 + let (clause, binds) = + build_export_where(&json!({"role_level": 1, "org_id": 42, "scope_project_ids": []})); + assert!(clause.contains("p.organization_id = ?"), "{}", clause); + assert!(!clause.contains("project_id IN"), "未配置范围不得额外收窄:{}", clause); + assert_eq!(binds, vec!["42"]); + + // 配置了项目范围 ⇒ 追加柜子级子查询(经 cabinets.project_id 收窄) + let (clause, binds) = build_export_where( + &json!({"role_level": 1, "org_id": 42, "scope_project_ids": [7, 9]}), + ); + assert!( + clause.contains("cr.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (?,?))"), + "导出必须叠加数据范围:{}", + clause + ); + assert_eq!(binds, vec!["42", "7", "9"], "绑定顺序 = 占位符顺序"); + + // 总管理员:组织与范围都不收窄 + let (clause, binds) = build_export_where( + &json!({"role_level": 2, "scope_project_ids": [7]}), + ); + assert!(clause.is_empty(), "总管理员豁免:{}", clause); + assert!(binds.is_empty()); + } + + /// 已带 cabinet_id 筛选时,范围条件用 AND 续接(不能覆盖原有筛选) + #[test] + fn test_build_export_where_scope_appends_to_existing_conditions() { + let (clause, binds) = build_export_where( + &json!({"cabinet_id": 5, "role_level": 1, "org_id": 42, "scope_project_ids": [7]}), + ); + assert!(clause.contains("cr.cabinet_id = ? AND cr.cabinet_id IN ("), "{}", clause); + assert_eq!(binds, vec!["5", "42", "7"]); + } +} diff --git a/software/api-server/src/routes/device_logs.rs b/software/api-server/src/routes/device_logs.rs index 87f5901..80813cc 100644 --- a/software/api-server/src/routes/device_logs.rs +++ b/software/api-server/src/routes/device_logs.rs @@ -59,38 +59,45 @@ struct DeviceLogRow { created_at: Option, } -/// 构建 WHERE 条件与绑定值(含组织数据隔离) +/// 构建 WHERE 条件与绑定值(含组织数据隔离 + 数据范围) fn build_device_log_filters( user: &CurrentUser, params: &DeviceLogQuery, ) -> (String, Vec) { - let mut conditions = Vec::new(); + let mut conditions: Vec = Vec::new(); let mut bind_values: Vec = Vec::new(); if let Some(cabinet_id) = params.cabinet_id { - conditions.push("dl.cabinet_id = ?"); + conditions.push("dl.cabinet_id = ?".to_string()); bind_values.push(cabinet_id.to_string()); } if let Some(log_type) = params.log_type { - conditions.push("dl.log_type = ?"); + conditions.push("dl.log_type = ?".to_string()); bind_values.push(log_type.to_string()); } if let Some(ref start_time) = params.start_time { - conditions.push("dl.created_at >= ?"); + conditions.push("dl.created_at >= ?".to_string()); bind_values.push(start_time.clone()); } if let Some(ref end_time) = params.end_time { - conditions.push("dl.created_at <= ?"); + conditions.push("dl.created_at <= ?".to_string()); bind_values.push(end_time.clone()); } // 追加组织数据隔离 let (org_cond, org_binds) = auth::org_condition(user, "dl"); if !org_cond.is_empty() { - conditions.push(org_cond.trim_start_matches(" AND ")); + conditions.push(org_cond.trim_start_matches(" AND ").to_string()); bind_values.extend(org_binds); } + // 追加数据范围(项目级):只保留范围内项目下设备产生的日志(未配置 = 不限制) + let (scope_cond, scope_binds) = user.effective_scope().cabinet_condition("dl.cabinet_id"); + if !scope_cond.is_empty() { + conditions.push(scope_cond.trim_start_matches(" AND ").to_string()); + bind_values.extend(scope_binds); + } + let where_clause = if conditions.is_empty() { String::new() } else { diff --git a/software/api-server/src/routes/energy_stats.rs b/software/api-server/src/routes/energy_stats.rs index f9bb4eb..16adae6 100644 --- a/software/api-server/src/routes/energy_stats.rs +++ b/software/api-server/src/routes/energy_stats.rs @@ -1,7 +1,21 @@ //! 能耗管理 API — 总览、排行、趋势、导出 //! -//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。 -//! 电费估算按 ¥0.80/kWh 计算(可配置),energy 单位为 Wh。 +//! **数据源:`charge_records` 实时聚合**(不再读写 `energy_stats` 表)。 +//! +//! `energy_stats` 是"按天快照"的汇总表,但全仓**没有任何写入方**(无 INSERT/REPLACE), +//! 生产与测试库该表恒为 0 行 → 原先的 summary / 柜排名 / 项目统计 / 趋势 / 导出**全部恒为 0** +//! (客户侧表现为"没用电")。真实数据源是 `charge_records`:每次充电结束由订单结算 +//! 写入一条(含 `cabinet_id` / `compartment_id` / `start_time` / `end_time` / `energy` / `max_temperature` / `status`)。 +//! 因此这里统一改为**按时间范围 + 组织隔离直接聚合 `charge_records`**。 +//! +//! 口径说明(详见 [`time_window`] 与 `charge_records` 的 DDL 注释): +//! - 时间字段统一用 `start_time`(不是 `end_time`):与 `h5.rs` 的今日统计 +//! (`DATE(cr.start_time) = CURDATE()`)以及"充电记录"列表的时间筛选口径一致, +//! 且每笔充电记录**只归属它开始的那一天**,不会因跨天而当天重复计数; +//! - `energy` 单位为 **Wh**(`workers/report_worker.rs` 结算时写入 `energy::mwh_to_wh(mwh)`); +//! - 电费估算按 ¥0.80/kWh 计算:`Wh × 80(分/kWh) / 1000 / 100` = 元。 +//! +//! `energy_stats` 表**保留不删**(历史语义/回滚余地),但本模块已完全不再引用它。 use axum::extract::{Query, State}; use axum::Json; @@ -14,6 +28,51 @@ use crate::middleware::auth::{self, CurrentUser}; /// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh const COST_PER_WH_CENT: i64 = 80; +/// 趋势查询 `range`(天数)的合法区间上限,防超大值拖垮查询 +const TREND_RANGE_MAX: i64 = 3650; +/// 趋势查询 `range` 的合法区间下限(至少 1 天) +const TREND_RANGE_MIN: i64 = 1; +/// 导出明细单次最大行数(防止无上限导出把内存/Excel 撑爆) +const EXPORT_MAX_ROWS: i64 = 100_000; + +/// 聚合表达式:`energy` 列在 pms 是 BIGINT、在 pms_dev 是 DECIMAL, +/// 统一 `CAST(... AS SIGNED)` 后交给 Rust `i64`;`COALESCE` 保证无数据时为 0 而非 NULL。 +const SUM_ENERGY_SIGNED: &str = "CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED)"; +/// 聚合表达式:充电次数。`COUNT(*)` 原生类型是 BIGINT UNSIGNED, +/// 不能直接解码成 `i64`(sqlx 类型不匹配),必须 CAST。 +const COUNT_CHARGES_SIGNED: &str = "CAST(COUNT(*) AS SIGNED)"; + +/// 能耗聚合的时间窗口 SQL 片段(全部按 `cr.start_time` 半开区间 `[起, 止)`)。 +/// +/// # 为什么用半开区间而不是 `DATE(cr.start_time) = CURDATE()` +/// `DATE(列) = 常量` 会让 `start_time` 上的索引失效(列被函数包裹 ⇒ 无法走 range scan), +/// 而 `cr.start_time >= 起 AND cr.start_time < 止` 是**等价的**且能命中 +/// `idx_charge_records_start_time`。时刻口径与 `DATE(...)` 完全一致: +/// 只按"充电开始的那一刻"归属,不用 `end_time`。 +mod time_window { + /// 今日:`[今天 00:00:00, 明天 00:00:00)` + pub const TODAY: &str = + "cr.start_time >= CURDATE() AND cr.start_time < CURDATE() + INTERVAL 1 DAY"; + + /// 本月:`[本月 1 日 00:00:00, 下月 1 日 00:00:00)` + /// + /// 原 `energy_stats` 实现只有下界(`stat_date >= 本月1日`),因为 `stat_date` 是"按天快照"、 + /// 不存在未来日期。换成时间戳列后补上上界:口径不变(仍只含本月), + /// 同时避免时钟偏移产生的未来行被算进本月。 + pub const THIS_MONTH: &str = "cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \ + AND cr.start_time < DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01')"; + + /// 导出用的"结束日期"(含当天):`end_date <= ?` 在 DATE 列上原本含当天, + /// 换成时间戳列后必须写成 `< 次日 00:00:00` 才是同样的"含当天"语义。 + pub const BEFORE_NEXT_DAY: &str = "cr.start_time < DATE_ADD(?, INTERVAL 1 DAY)"; +} + +/// 把外部传入的 `range` 夹到合法区间,防负数 / 0 / 超大值 +/// (负数会让 `DATE_SUB(..., INTERVAL -5 DAY)` 变成"未来 5 天",超大值会扫全表)。 +fn clamp_range(range: i64) -> i64 { + range.clamp(TREND_RANGE_MIN, TREND_RANGE_MAX) +} + /// 趋势查询参数 #[derive(Debug, Deserialize)] pub struct TrendQuery { @@ -76,6 +135,72 @@ struct EnergyDetailRow { fn default_granularity() -> String { "day".to_string() } fn default_range() -> i64 { 30 } +// --------------------------------------------------------------------------- +// 过滤条件构造(组织隔离 + 数据范围,两处口径必须完全一致) +// --------------------------------------------------------------------------- + +/// `charge_records cr` 上的过滤片段:组织隔离 **∧** 数据范围(项目级) +/// +/// - 未配置数据范围 ⇒ 只有组织条件(与今天完全一致); +/// - 无组织的客户侧账号 ⇒ ` AND 1=0`(空集,绝不退化成"全部"); +/// - 总管理员 ⇒ 两个条件都为空(豁免)。 +/// +/// 返回的是**条件后缀**(调用方拼在 `WHERE {时间窗口}` 之后)⇒ 这里直接 `push_str` +/// 追加 ` AND ...`;**不能**用 `append_where`(空串时会补 `WHERE`,拼出 `WHERE a WHERE b`)。 +fn charge_record_filters(user: &CurrentUser) -> (String, Vec) { + let (mut cond, mut binds): (String, Vec) = if user.role_level >= 2 { + (String::new(), vec![]) + } else if let Some(oid) = user.organization_id { + ( + " AND cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(), + vec![oid.to_string()], + ) + } else { + (" AND 1=0".to_string(), vec![]) + }; + + let (scope_cond, scope_binds) = user.effective_scope().cabinet_condition("cr.cabinet_id"); + cond.push_str(&scope_cond); + binds.extend(scope_binds); + + (cond, binds) +} + +/// 统计当前用户可见的柜子数量(`online_only = true` 时只算在线柜子) +/// +/// 口径与设备列表一致:非总管理员经 `cabinets → projects` 组织过滤, +/// 再叠加数据范围的项目收窄。 +async fn count_visible_cabinets( + pool: &sqlx::MySqlPool, + user: &CurrentUser, + online_only: bool, +) -> Result { + if user.role_level >= 2 { + let sql = if online_only { + "SELECT COUNT(*) FROM cabinets WHERE status = 1" + } else { + "SELECT COUNT(*) FROM cabinets" + }; + return Ok(sqlx::query_scalar(sql).fetch_one(pool).await?); + } + + let Some(oid) = user.organization_id else { + return Ok(0); + }; + + let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id"); + let status_cond = if online_only { " AND c.status = 1" } else { "" }; + let sql = format!( + "SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?{}{}", + status_cond, scope_cond + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(oid); + for bind in &scope_binds { + query = query.bind(bind); + } + Ok(query.fetch_one(pool).await?) +} + /// GET /api/energy-stats/summary — 总览 /// /// 返回今日/本月能耗、电费估算、柜子/在线数量。 @@ -86,64 +211,34 @@ pub async fn get_summary( auth::check_permission(&user, "energy:view")?; let pool = &state.mysql; - // 组织过滤子查询 - let (org_filter, org_binds): (String, Vec) = if user.role_level >= 2 { - (String::new(), vec![]) - } else if let Some(oid) = user.organization_id { - ( - " AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(), - vec![oid.to_string()], - ) - } else { - (" AND 1=0".to_string(), vec![]) - }; + // 今天/本月能耗的过滤条件 = 组织隔离 + 数据范围(项目级,未配置 = 不限制) + let (org_filter, org_binds) = charge_record_filters(&user); - // 今日总能耗 + // 今日总能耗:聚合 charge_records(原来是 energy_stats.stat_date = CURDATE()) let today_sql = format!( - "SELECT CAST(COALESCE(SUM(total_energy), 0) AS SIGNED) FROM energy_stats es WHERE stat_date = CURDATE(){}", + "SELECT {} FROM charge_records cr WHERE {}{}", + SUM_ENERGY_SIGNED, + time_window::TODAY, org_filter ); let mut today_q = sqlx::query_scalar::<_, i64>(&today_sql); for v in &org_binds { today_q = today_q.bind(v); } let today_energy: i64 = today_q.fetch_one(pool).await?; - // 本月总能耗 + // 本月总能耗:聚合 charge_records(原来是 energy_stats.stat_date >= 本月1日) let month_sql = format!( - "SELECT CAST(COALESCE(SUM(total_energy), 0) AS SIGNED) FROM energy_stats es \ - WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}", + "SELECT {} FROM charge_records cr WHERE {}{}", + SUM_ENERGY_SIGNED, + time_window::THIS_MONTH, org_filter ); let mut month_q = sqlx::query_scalar::<_, i64>(&month_sql); for v in &org_binds { month_q = month_q.bind(v); } let month_energy: i64 = month_q.fetch_one(pool).await?; - // 柜子总数(含组织过滤) - let cabinet_count: i64 = if user.role_level >= 2 { - sqlx::query_scalar("SELECT COUNT(*) FROM cabinets") - .fetch_one(pool).await? - } else if let Some(oid) = user.organization_id { - sqlx::query_scalar( - "SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?" - ) - .bind(oid) - .fetch_one(pool).await? - } else { - 0 - }; - - // 在线柜子数 - let online_count: i64 = if user.role_level >= 2 { - sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1") - .fetch_one(pool).await? - } else if let Some(oid) = user.organization_id { - sqlx::query_scalar( - "SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE c.status = 1 AND p.organization_id = ?" - ) - .bind(oid) - .fetch_one(pool).await? - } else { - 0 - }; + // 柜子总数 / 在线柜子数(含组织过滤 + 数据范围) + let cabinet_count = count_visible_cabinets(pool, &user, false).await?; + let online_count = count_visible_cabinets(pool, &user, true).await?; Ok(Json(json!({ "code": 0, @@ -168,6 +263,8 @@ pub async fn get_cabinet_ranking( auth::check_permission(&user, "energy:view")?; let pool = &state.mysql; + // 组织过滤:INNER JOIN projects 把可见范围限制在用户所属组织 + // (口径与原实现完全一致,只是不再经过 energy_stats) let (org_join, org_binds): (String, Vec) = if user.role_level >= 2 { (String::new(), vec![]) } else if let Some(oid) = user.organization_id { @@ -179,22 +276,45 @@ pub async fn get_cabinet_ranking( (" JOIN projects p ON 1=0".to_string(), vec![]) }; + // 数据范围(项目级)叠加:未配置 = 不限制;总管理员豁免。 + // 这里是 **JOIN 片段后缀**(拼在 `FROM cabinets c {}` 位置),直接原样拼接 ` AND ...`。 + let (scope_cond, scope_binds) = user.effective_scope().project_condition("c.project_id"); + let (org_join, org_binds) = { + let mut join = org_join; + let mut binds = org_binds; + join.push_str(&scope_cond); + binds.extend(scope_binds); + (join, binds) + }; + + // 今日/本月能耗改为「按 cabinet_id 聚合 charge_records」的两个派生表: + // 每个柜子至多一行,LEFT JOIN 到 cabinets 后语义与原 energy_stats 版本一致。 + // + // `COALESCE(c.abstract_id, '')`:`abstract_id` 已被迁移改成**可空**(老数据/联调库里就有 NULL), + // 而 `CabinetRankRow.abstract_id` 是 `String` ⇒ 遇到 NULL 会让整条查询解码失败、接口 500。 + // 响应字段名与类型不变(仍是字符串),只是把 NULL 显示为空串而不是让整个排行崩掉。 let sql = format!( - "SELECT c.id, c.abstract_id, c.name, \ - CAST(COALESCE(es_today.total_energy, 0) AS SIGNED) AS today_energy, \ - CAST(COALESCE(es_month.month_energy, 0) AS SIGNED) AS month_energy \ + "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 energy_stats es_today \ - ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \ LEFT JOIN ( \ - SELECT cabinet_id, SUM(total_energy) AS month_energy \ - FROM energy_stats \ - WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \ - GROUP BY cabinet_id \ - ) es_month ON c.id = es_month.cabinet_id \ + 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 {month} \ + GROUP BY cr.cabinet_id \ + ) cr_month ON c.id = cr_month.cabinet_id \ {} \ ORDER BY today_energy DESC, c.id ASC", - org_join + org_join, + today = time_window::TODAY, + month = time_window::THIS_MONTH, ); let mut query = sqlx::query_as::<_, CabinetRankRow>(&sql); for v in &org_binds { query = query.bind(v); } @@ -235,25 +355,45 @@ pub async fn get_project_stats( (" WHERE 1=0".to_string(), vec![]) }; + // 数据范围(项目级)叠加:只统计范围内项目的能耗(未配置 = 不限制)。 + // 这里是 **WHERE 后缀**(拼在 `GROUP BY` 之前),直接原样拼接 ` AND ...`。 + let (scope_cond, scope_binds) = user.effective_scope().project_condition("p.id"); + let (org_filter, org_binds) = { + let mut cond = org_filter; + let mut binds = org_binds; + cond.push_str(&scope_cond); + binds.extend(scope_binds); + (cond, binds) + }; + + // `charge_records` 只有 `cabinet_id`,项目维度必须经 cabinets → projects 关联; + // 两个派生表都**按 cabinet_id 聚合**(每柜一行),避免"记录级 JOIN 让 SUM 被放大 N 倍": + // 若直接 LEFT JOIN charge_records 再 GROUP BY 项目,则本月汇总会被今天的记录条数重复累加。 let sql = format!( "SELECT p.id, p.name, \ COUNT(DISTINCT c.id) AS cabinet_count, \ - CAST(COALESCE(SUM(es_today.total_energy), 0) AS SIGNED) AS today_energy, \ - CAST(COALESCE(SUM(es_month.month_energy), 0) AS SIGNED) AS month_energy \ + 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 energy_stats es_today \ - ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \ LEFT JOIN ( \ - SELECT cabinet_id, SUM(total_energy) AS month_energy \ - FROM energy_stats \ - WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \ - GROUP BY cabinet_id \ - ) es_month ON c.id = es_month.cabinet_id \ + 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 {month} \ + 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", - org_filter + org_filter, + today = time_window::TODAY, + month = time_window::THIS_MONTH, ); let mut query = sqlx::query_as::<_, ProjectStatRow>(&sql); for v in &org_binds { query = query.bind(v); } @@ -288,60 +428,41 @@ pub async fn get_trend( auth::check_permission(&user, "energy:view")?; let pool = &state.mysql; - // 组织过滤:es.cabinet_id → cabinets.project_id → projects.organization_id - let (org_filter, org_binds): (String, Vec) = if user.role_level >= 2 { - (String::new(), vec![]) - } else if let Some(oid) = user.organization_id { - ( - " AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(), - vec![oid.to_string()], - ) - } else { - (" AND 1=0".to_string(), vec![]) + // 组织过滤:cr.cabinet_id → cabinets.project_id → projects.organization_id + // (口径与原实现完全一致,仅把别名 es → cr)+ 数据范围(项目级) + let (org_filter, org_binds) = charge_record_filters(&user); + + // range 必须夹紧:负数会让 DATE_SUB 变成"未来 N 天",超大值会拖垮查询 + let range = clamp_range(params.range); + + // 前端的粒度取值只有 day / week / month(见 software/web/src/pages/energy/index.tsx 的 + // Select options),未知取值按 day 处理(与原实现一致,不报错)。 + // + // 三档都改为「按 period 分组聚合 charge_records」: + // 原 day 分支没有 GROUP BY(直接吐 energy_stats 的行),而换到 charge_records 后 + // 一天会有多条记录,**必须按 DATE(start_time) 分组**,否则同一 period 会重复出现多行, + // 折线图/柱状图的 X 轴会重复。period 文本格式与原实现保持一致 + // (day = 'YYYY-MM-DD',week = '%Y-%u',month = '%Y-%m'),前端只把它当分类标签用。 + let (period_expr, group_expr) = match params.granularity.as_str() { + "week" => ("DATE_FORMAT(cr.start_time, '%Y-%u')", "period"), + "month" => ("DATE_FORMAT(cr.start_time, '%Y-%m')", "period"), + _ => ("DATE_FORMAT(cr.start_time, '%Y-%m-%d')", "period"), }; - let rows = match params.granularity.as_str() { - "week" => { - let sql = format!( - "SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \ - CAST(SUM(total_energy) AS SIGNED) AS energy, CAST(SUM(charge_count) AS SIGNED) AS charge_count \ - FROM energy_stats es \ - WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \ - GROUP BY period ORDER BY period ASC", - org_filter - ); - let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range); - for v in &org_binds { q = q.bind(v); } - q.fetch_all(pool).await? - } - "month" => { - let sql = format!( - "SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \ - CAST(SUM(total_energy) AS SIGNED) AS energy, CAST(SUM(charge_count) AS SIGNED) AS charge_count \ - FROM energy_stats es \ - WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \ - GROUP BY period ORDER BY period ASC", - org_filter - ); - let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range); - for v in &org_binds { q = q.bind(v); } - q.fetch_all(pool).await? - } - _ => { - // day - let sql = format!( - "SELECT stat_date AS period, \ - total_energy AS energy, charge_count AS charge_count \ - FROM energy_stats es \ - WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \ - ORDER BY stat_date ASC", - org_filter - ); - let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range); - for v in &org_binds { q = q.bind(v); } - q.fetch_all(pool).await? - } - }; + let sql = format!( + "SELECT {} AS period, {} AS energy, {} AS charge_count \ + FROM charge_records cr \ + WHERE cr.start_time >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \ + GROUP BY {} ORDER BY period ASC", + period_expr, + SUM_ENERGY_SIGNED, + COUNT_CHARGES_SIGNED, + org_filter, + group_expr + ); + let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(range); + for v in &org_binds { q = q.bind(v); } + let rows = q.fetch_all(pool).await?; let list: Vec = rows.iter().map(|r| { json!({ @@ -371,7 +492,7 @@ pub async fn export_energy_stats( let user_id = user.user_id; let file_name = generate_export_filename(¶ms); - // 序列化查询参数(含组织过滤信息) + // 序列化查询参数(含组织过滤信息 + 数据范围) let query_json = serde_json::to_string(&json!({ "cabinet_id": params.cabinet_id, "project_id": params.project_id, @@ -379,6 +500,9 @@ pub async fn export_energy_stats( "end_date": params.end_date, "org_id": user.organization_id, "role_level": user.role_level, + // 数据范围(项目级白名单)必须随任务一起落库:导出在后台跑, + // 不带过去就会绕过 scope(越权导出更大范围) + "scope_project_ids": user.effective_scope().project_ids, })) .unwrap_or_default(); @@ -427,6 +551,9 @@ fn generate_export_filename(params: &ExportQuery) -> String { } /// 构建导出条件 +/// +/// 列名口径:`cabinet_id` 由 `es.` 改为 `cr.`;日期由 `stat_date`(DATE 列)改为 +/// `start_time`(TIMESTAMP 列),并保持「含当天」的语义(见 `time_window::BEFORE_NEXT_DAY`)。 fn build_export_clause( cabinet_id: Option, project_id: Option, @@ -437,7 +564,7 @@ fn build_export_clause( let mut bind_values = Vec::new(); if let Some(id) = cabinet_id { - conditions.push("es.cabinet_id = ?"); + conditions.push("cr.cabinet_id = ?"); bind_values.push(id.to_string()); } if let Some(id) = project_id { @@ -445,11 +572,11 @@ fn build_export_clause( bind_values.push(id.to_string()); } if let Some(d) = start_date { - conditions.push("es.stat_date >= ?"); + conditions.push("cr.start_time >= ?"); bind_values.push(d.to_string()); } if let Some(d) = end_date { - conditions.push("es.stat_date <= ?"); + conditions.push(time_window::BEFORE_NEXT_DAY); bind_values.push(d.to_string()); } @@ -495,7 +622,7 @@ fn build_export_where(params: &Value) -> (String, Vec) { if role_level < 2 { if let Some(oid) = org_id { let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " }; - where_clause.push_str(&format!("{}es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix)); + where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix)); bind_values.push(oid.to_string()); } else { let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " }; @@ -503,25 +630,54 @@ fn build_export_where(params: &Value) -> (String, Vec) { } } + // 数据范围(非总管理员):与在线查询同一套项目级收窄 + if role_level < 2 { + let project_ids: Vec = params + .get("scope_project_ids") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect()) + .unwrap_or_default(); + let (scope_cond, scope_binds) = + auth::UserScope { project_ids }.cabinet_condition("cr.cabinet_id"); + auth::append_where(&mut where_clause, &scope_cond); + bind_values.extend(scope_binds); + } + (where_clause, bind_values) } -/// 查询能耗明细行 +/// 查询能耗明细行(按「日期 + 柜子」聚合 `charge_records`) +/// +/// 原来直接读 `energy_stats`(每柜每天一行);改为聚合后**必须显式分组**, +/// 分组键 = `DATE(start_time)` + `cabinet_id` + 柜名/项目名(`ONLY_FULL_GROUP_BY` 下 +/// 非聚合列必须出现在 GROUP BY 中),输出列与原结构体完全一致。 async fn fetch_energy_detail_rows( pool: &sqlx::MySqlPool, where_clause: &str, bind_values: &[String], ) -> Result, sqlx::Error> { + // 组织隔离/筛选条件加在 WHERE 里,可能整段为空 ⇒ 这里必须自己兜住 + // `start_time IS NULL`(该列可空):`DATE(NULL)` 会是 NULL, + // 反序列化成 `chrono::NaiveDate` 会直接失败,让整个导出任务挂掉。 + let where_clause = if where_clause.is_empty() { + "WHERE cr.start_time IS NOT NULL".to_string() + } else { + format!("{} AND cr.start_time IS NOT NULL", where_clause) + }; + let sql = format!( - "SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \ + "SELECT DATE(cr.start_time) AS stat_date, cr.cabinet_id, c.name AS cabinet_name, \ p.name AS project_name, \ - es.total_energy, \ - es.charge_count \ - FROM energy_stats es \ - LEFT JOIN cabinets c ON es.cabinet_id = c.id \ + 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 \ - {} ORDER BY es.stat_date DESC, es.cabinet_id ASC", - where_clause + {} \ + GROUP BY DATE(cr.start_time), cr.cabinet_id, c.name, p.name \ + ORDER BY stat_date DESC, cr.cabinet_id ASC \ + LIMIT {}", + where_clause, EXPORT_MAX_ROWS ); let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql); @@ -616,3 +772,106 @@ async fn generate_energy_excel( tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path); Ok(()) } + +// --------------------------------------------------------------------------- +// 单元测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// 趋势 `range` 夹紧:负数会让 `DATE_SUB(CURDATE(), INTERVAL -5 DAY)` 变成"未来 5 天", + /// 0 会让窗口退化成"仅今天零点以后",超大值会全表扫描。 + #[test] + fn test_clamp_range_bounds_illegal_values() { + assert_eq!(clamp_range(30), 30, "合法值应原样保留"); + assert_eq!(clamp_range(0), TREND_RANGE_MIN, "0 应被抬到下限"); + assert_eq!(clamp_range(-5), TREND_RANGE_MIN, "负数应被抬到下限"); + assert_eq!(clamp_range(999_999), TREND_RANGE_MAX, "超大值应被压到上限"); + } + + /// 时间窗口片段必须只按 `start_time` 归属(不得出现 `end_time` / `stat_date`), + /// 且今日/本月都必须是半开区间(索引可用的写法)。 + #[test] + fn test_time_window_uses_start_time_half_open_ranges() { + assert!(time_window::TODAY.contains("cr.start_time >= CURDATE()")); + assert!(time_window::TODAY.contains("cr.start_time < CURDATE() + INTERVAL 1 DAY")); + assert!(time_window::THIS_MONTH.contains("cr.start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01')")); + assert!(time_window::THIS_MONTH.contains("INTERVAL 1 MONTH")); + // 导出结束日期必须"含当天" ⇒ 用 < 次日 00:00:00,而不是 <= 当天(会丢掉当天全部记录) + assert_eq!(time_window::BEFORE_NEXT_DAY, "cr.start_time < DATE_ADD(?, INTERVAL 1 DAY)"); + + for window in [time_window::TODAY, time_window::THIS_MONTH] { + assert!(!window.contains("end_time"), "口径必须是 start_time:{}", window); + assert!(!window.contains("stat_date"), "不得再引用 energy_stats.stat_date:{}", window); + } + } + + /// 导出 WHERE 构建:列名必须指向 charge_records(`cr.`), + /// 日期条件必须落在 `cr.start_time`,且参数一律走占位符(不做字符串拼接)。 + #[test] + fn test_build_export_clause_targets_charge_records() { + let (clause, binds) = build_export_clause( + Some(7), + Some(3), + Some("2026-01-01"), + Some("2026-01-31"), + ); + assert!(clause.starts_with("WHERE "), "非空条件应带 WHERE:{}", clause); + assert!(clause.contains("cr.cabinet_id = ?"), "柜子过滤应指向 cr:{}", clause); + assert!(clause.contains("c.project_id = ?"), "项目过滤应指向 cabinets:{}", clause); + assert!(clause.contains("cr.start_time >= ?"), "起始日期应落在 cr.start_time:{}", clause); + assert!(clause.contains(time_window::BEFORE_NEXT_DAY), "结束日期应含当天:{}", clause); + assert!(!clause.contains("energy_stats"), "不得再引用 energy_stats:{}", clause); + assert_eq!(binds, vec!["7", "3", "2026-01-01", "2026-01-31"]); + } + + /// 无条件时应返回空子句(由 `fetch_energy_detail_rows` 自己补 `start_time IS NOT NULL` 兜底) + #[test] + fn test_build_export_clause_empty_when_no_filters() { + let (clause, binds) = build_export_clause(None, None, None, None); + assert!(clause.is_empty()); + assert!(binds.is_empty()); + } + + /// 组织隔离:非总管理员 + 有组织 ⇒ 必须追加 `cr.cabinet_id IN (...)` 子查询并带上组织 ID; + /// 非总管理员 + 无组织 ⇒ 必须追加 `1=0` 空集(**绝不退化成"全部"**)。 + #[test] + fn test_build_export_where_keeps_org_isolation() { + // 总管理员:不加任何组织条件 + let (clause, binds) = build_export_where(&json!({"role_level": 2})); + assert!(clause.is_empty(), "总管理员不应有额外条件:{}", clause); + assert!(binds.is_empty()); + + // 组织管理员:追加组织子查询 + let (clause, binds) = build_export_where(&json!({"role_level": 1, "org_id": 42})); + assert!( + clause.contains("cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)"), + "组织隔离条件丢失:{}", + clause + ); + assert_eq!(binds, vec!["42"]); + + // 客户侧账号但无组织:空集 + let (clause, binds) = build_export_where(&json!({"role_level": 0})); + assert_eq!(clause, "WHERE 1=0"); + assert!(binds.is_empty()); + + // 已有条件时组织条件用 AND 拼接(不能覆盖原有筛选) + let (clause, _) = build_export_where(&json!({"cabinet_id": 9, "role_level": 1, "org_id": 42})); + assert!(clause.contains("cr.cabinet_id = ? AND cr.cabinet_id IN ("), "拼接方式错误:{}", clause); + } + + /// 电费估算口径:`energy` 单位 Wh,单价 0.80 元/kWh ⇒ 1000 Wh = 0.80 元。 + #[test] + fn test_estimated_cost_unit_is_wh_times_80_cent_per_kwh() { + let month_energy_wh: i64 = 1000; + let cost = (month_energy_wh * COST_PER_WH_CENT / 1000) as f64 / 100.0; + assert!((cost - 0.80).abs() < f64::EPSILON, "1000Wh 应为 0.8 元,实际 {}", cost); + + // 22 Wh(联调实测值)≈ 0.0176 元,不应被当成 22 kWh(那会是 17.6 元) + let small = (22_i64 * COST_PER_WH_CENT / 1000) as f64 / 100.0; + assert!(small < 0.02, "22Wh 不应被当成 22kWh:{}", small); + } +} diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index d90bed4..1a59a76 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -10,11 +10,12 @@ //! - POST /api/charge/stop — 停止充电 //! - POST /api/door/open — 开门 //! - GET /api/cabinets/scan/:abstract_id — 扫码验证 +//! - GET /api/my-organizations — 当前用户可见的组织(仅需登录;工厂全量 / 客户仅本组织) use axum::extract::{Multipart, Path, Query, State}; use axum::Json; use redis; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::io::Write; @@ -24,6 +25,8 @@ use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; +use super::cabinet_tree::{derive_channel_flags, ChannelCounts}; + // --------------------------------------------------------------------------- // 请求体定义 // --------------------------------------------------------------------------- @@ -70,6 +73,10 @@ struct ProjectRow { } /// 柜子列表行(含通道统计) +/// +/// ⚠️ 这里**只保留 DB 能给准的三个计数**(`channel_count` / `idle_count` / `charging_count`)。 +/// `full_count` / `fault_count` 不在此结构体里 —— `compartments.status` 全仓只写 0/1(占用语义), +/// DB 永远给不出充满/故障,它们只能由 Redis 通道状态派生(见 [`CabinetRealtimeMaps::counts`])。 #[derive(Debug, sqlx::FromRow)] struct H5CabinetRow { id: i64, @@ -85,8 +92,6 @@ struct H5CabinetRow { channel_count: i64, idle_count: i64, charging_count: i64, - full_count: i64, - fault_count: i64, } /// 仓控板行 @@ -127,7 +132,22 @@ struct CabinetImeiRow { // 仪表盘 // --------------------------------------------------------------------------- -/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离) +/// 数据范围(项目级)过滤片段:`cond` 以 ` AND ` 开头(拼在已有 WHERE 之后),`binds` 顺序与占位符一致 +/// +/// 未配置数据范围(或总管理员)时 `cond` 为空串、`binds` 为空 ⇒ 各统计 SQL 与今天逐字一致。 +struct ScopeFilter<'a> { + cond: &'a str, + binds: &'a [String], +} + +/// 构造作用于 `projects p` 的数据范围过滤片段 +fn scope_filter_for_projects(scope: &crate::middleware::auth::UserScope) -> (String, Vec) { + scope.project_condition("p.id") +} + +/// 仪表盘首页 +/// +/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离 + 数据范围) pub async fn get_dashboard( user: CurrentUser, State(state): State, @@ -140,39 +160,41 @@ pub async fn get_dashboard( let is_super = user.role_level >= 2 && query.org_id.is_none(); let org_id = query.org_id.or(user.organization_id); + // 数据范围(项目级):总管理员豁免;未配置 = 不限制(片段为空) + let (scope_cond, scope_binds) = scope_filter_for_projects(&user.effective_scope()); + let scope = ScopeFilter { cond: &scope_cond, binds: &scope_binds }; + // 统计在线柜子数(带组织隔离) - let online_cabinets = count_online_cabinets(pool, is_super, org_id).await?; + let online_cabinets = count_online_cabinets(pool, is_super, org_id, &scope).await?; // 统计总通道数(带组织隔离) - let total_channels = count_total_compartments(pool, is_super, org_id).await?; + let total_channels = count_total_compartments(pool, is_super, org_id, &scope).await?; // 统计充电中仓体数(带组织隔离) - let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?; + let charging_count = count_compartments_by_status(pool, is_super, org_id, 1, &scope).await?; - // 统计空闲仓体数(带组织隔离) - let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?; + // 统计空闲仓体数(带组织隔离):`status = 0` = 空闲(占用语义,DB 给得准) + let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0, &scope).await?; - // 统计故障仓体数(带组织隔离) - let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?; - - // 统计充满仓体数(status=2,带组织隔离) - let full_count = count_compartments_by_status(pool, is_super, org_id, 2).await?; - - // 统计故障数(status=3,带组织隔离) - let fault_count = count_compartments_by_status(pool, is_super, org_id, 3).await?; + // 充满 / 故障:**DB 给不出**(`compartments.status` 只写 0/1,2/3 永不出现), + // 只能由通道实时状态派生 —— 与设备列表/PC 设备卡片同一套规则(`derive_channel_flags`)。 + // 拿不到 Redis 数据(设备离线 / 无通道键 / Redis 不可用)时按 0 计,不猜测。 + let (full_count, fault_count) = count_derived_full_fault(&state, is_super, org_id, &scope).await; + // `fault_channels` 与 `fault_count` 同源同义(历史字段名重复),一并给派生值 + let fault_channels = fault_count; // 今日充电次数(带组织隔离) - let today_charge_count = count_today_charges(pool, is_super, org_id).await?; + let today_charge_count = count_today_charges(pool, is_super, org_id, &scope).await?; // 今日充电量(kWh,带组织隔离) - let today_energy = sum_today_energy(pool, is_super, org_id).await?; + let today_energy = sum_today_energy(pool, is_super, org_id, &scope).await?; // 今日充电时长(小时,带组织隔离) - let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?; + let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id, &scope).await?; // 故障告警(取最近10条故障仓体,返回项目名+柜名) - let alerts = fetch_fault_alerts(pool, is_super, org_id).await?; + let alerts = fetch_fault_alerts(pool, is_super, org_id, &scope).await?; - // 项目摘要(带组织过滤) + // 项目摘要(带组织过滤 + 数据范围) let projects = fetch_project_summaries(pool, &user, org_id).await?; Ok(Json(json!({ @@ -195,11 +217,12 @@ pub async fn get_dashboard( }))) } -/// 统计在线柜子数(带组织隔离) +/// 统计在线柜子数(带组织隔离 + 数据范围) async fn count_online_cabinets( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, + scope: &ScopeFilter<'_>, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); @@ -209,23 +232,27 @@ async fn count_online_cabinets( .fetch_one(pool) .await? } else { - sqlx::query_scalar( + let sql = format!( r#"SELECT COUNT(*) FROM cabinets cab INNER JOIN projects p ON cab.project_id = p.id - WHERE cab.status = 1 AND p.organization_id = ?"#, - ) - .bind(org_id.unwrap()) - .fetch_one(pool) - .await? + WHERE cab.status = 1 AND p.organization_id = ?{}"#, + scope.cond + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(org_id.unwrap()); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_one(pool).await? }; Ok(count) } -/// 统计总仓体数(带组织隔离) +/// 统计总仓体数(带组织隔离 + 数据范围) async fn count_total_compartments( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, + scope: &ScopeFilter<'_>, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); @@ -240,26 +267,30 @@ async fn count_total_compartments( .fetch_one(pool) .await? } else { - sqlx::query_scalar( + let sql = format!( r#"SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets c ON cb.cabinet_id = c.id JOIN projects p ON c.project_id = p.id - WHERE c.status = 1 AND p.organization_id = ?"#, - ) - .bind(org_id.unwrap()) - .fetch_one(pool) - .await? + WHERE c.status = 1 AND p.organization_id = ?{}"#, + scope.cond + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(org_id.unwrap()); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_one(pool).await? }; Ok(count) } -/// 按状态统计仓体数(带组织隔离,通过 cabin_boards → cabinets → projects 链路) +/// 按状态统计仓体数(带组织隔离 + 数据范围,通过 cabin_boards → cabinets → projects 链路) async fn count_compartments_by_status( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, status: i8, + scope: &ScopeFilter<'_>, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); @@ -270,26 +301,98 @@ async fn count_compartments_by_status( .fetch_one(pool) .await? } else { - sqlx::query_scalar( + let sql = format!( r#"SELECT COUNT(*) FROM compartments comp INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id INNER JOIN cabinets cab ON cb.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id - WHERE comp.status = ? AND p.organization_id = ?"#, - ) - .bind(status) - .bind(org_id.unwrap()) - .fetch_one(pool) - .await? + WHERE comp.status = ? AND p.organization_id = ?{}"#, + scope.cond + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(status).bind(org_id.unwrap()); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_one(pool).await? }; Ok(count) } -/// 今日充电次数(带组织隔离) +/// 统计范围内的「充满 / 故障」通道数(**由通道实时状态派生**,带组织隔离) +/// +/// 为什么不能用 SQL:`compartments.status` 只表达**占用**语义(`0 空闲 / 1 充电中`), +/// 平台从不写 2/3,所以 `WHERE comp.status = 2/3` 永远返回 0 —— 这正是首页统计行 +/// "充满""故障"恒为 0 的原因。改为:按可见范围取柜子 → 拼通道 key → 一次 pipeline +/// 读 `batt_status`/`pow_status` → 复用 `cabinet_tree::derive_channel_counts` +/// 的优先级(fault > full > charging > idle)分区计数。 +/// +/// 可见范围与 [`count_compartments_by_status`] 保持一致:总管理员 = 全部柜子;否则 = 本组织项目下的柜子。 +/// **拿不到 Redis 数据时按 0 计**(离线 / 无通道键 / Redis 不可用),绝不猜测。 +async fn count_derived_full_fault( + state: &AppState, + is_super: bool, + org_id: Option, + scope: &ScopeFilter<'_>, +) -> (i64, i64) { + let cabinet_ids = match fetch_scope_cabinet_ids(&state.mysql, is_super, org_id, scope).await { + Ok(ids) => ids, + Err(e) => { + tracing::error!("[h5] 首页计数:柜子范围查询失败,充满/故障按 0 计 err={}", e); + return (0, 0); + } + }; + let entries = match super::cabinet_tree::fetch_channel_keys(&state.mysql, &cabinet_ids).await { + Ok(entries) => entries, + Err(e) => { + tracing::error!("[h5] 首页计数:通道 key 查询失败,充满/故障按 0 计 err={}", e); + return (0, 0); + } + }; + + let counts: HashMap = + super::cabinet_tree::derive_counts_by_cabinet(state, &entries).await; + + counts + .values() + .fold((0_i64, 0_i64), |(full, fault), c| (full + c.2, fault + c.3)) +} + +/// 取统计范围内的柜子 ID:总管理员 = 全部柜子;组织用户 = 本组织**且落在数据范围内**的柜子;无组织 = 空集 +/// (口径与 [`count_compartments_by_status`] 完全一致) +async fn fetch_scope_cabinet_ids( + pool: &sqlx::MySqlPool, + is_super: bool, + org_id: Option, + scope: &ScopeFilter<'_>, +) -> Result, sqlx::Error> { + if is_super { + return sqlx::query_scalar("SELECT id FROM cabinets").fetch_all(pool).await; + } + match org_id { + Some(oid) => { + let sql = format!( + r#"SELECT c.id FROM cabinets c + INNER JOIN projects p ON c.project_id = p.id + WHERE p.organization_id = ?{}"#, + scope.cond + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(oid); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_all(pool).await + } + // `organization_id` 为 NULL 的客户侧账号看不到任何设备(空集,不是报错,也不退化成"全部") + None => Ok(Vec::new()), + } +} + +/// 今日充电次数(带组织隔离 + 数据范围) async fn count_today_charges( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, + scope: &ScopeFilter<'_>, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); @@ -301,24 +404,28 @@ async fn count_today_charges( .fetch_one(pool) .await? } else { - sqlx::query_scalar( + let sql = format!( r#"SELECT COUNT(*) FROM charge_records cr INNER JOIN cabinets cab ON cr.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id - WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#, - ) - .bind(org_id.unwrap()) - .fetch_one(pool) - .await? + WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?{}"#, + scope.cond + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(org_id.unwrap()); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_one(pool).await? }; Ok(count) } -/// 今日充电量(kWh,带组织隔离) +/// 今日充电量(kWh,带组织隔离 + 数据范围) async fn sum_today_energy( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, + scope: &ScopeFilter<'_>, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); @@ -330,24 +437,28 @@ async fn sum_today_energy( .fetch_one(pool) .await? } else { - sqlx::query_scalar( + let sql = format!( r#"SELECT CAST(COALESCE(SUM(cr.energy), 0) AS SIGNED) FROM charge_records cr INNER JOIN cabinets cab ON cr.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id - WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#, - ) - .bind(org_id.unwrap()) - .fetch_one(pool) - .await? + WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?{}"#, + scope.cond + ); + let mut query = sqlx::query_scalar::<_, Option>(&sql).bind(org_id.unwrap()); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_one(pool).await? }; Ok(energy.unwrap_or(0)) } -/// 今日充电时长(秒,带组织隔离) +/// 今日充电时长(秒,带组织隔离 + 数据范围) async fn sum_today_charge_hours( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, + scope: &ScopeFilter<'_>, ) -> Result { if !is_super && org_id.is_none() { return Ok(0); @@ -363,26 +474,30 @@ async fn sum_today_charge_hours( .fetch_one(pool) .await? } else { - sqlx::query_scalar( + let sql = format!( r#"SELECT CAST(COALESCE(SUM( TIMESTAMPDIFF(SECOND, cr.start_time, COALESCE(cr.end_time, NOW())) ), 0) AS SIGNED) FROM charge_records cr INNER JOIN cabinets cab ON cr.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id - WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#, - ) - .bind(org_id.unwrap()) - .fetch_one(pool) - .await? + WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?{}"#, + scope.cond + ); + let mut query = sqlx::query_scalar::<_, Option>(&sql).bind(org_id.unwrap()); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_one(pool).await? }; Ok(total_seconds.unwrap_or(0)) } -/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离) +/// 获取故障告警列表(返回项目名+柜名+通道号,带组织隔离 + 数据范围) async fn fetch_fault_alerts( pool: &sqlx::MySqlPool, is_super: bool, org_id: Option, + scope: &ScopeFilter<'_>, ) -> Result, AppError> { if !is_super && org_id.is_none() { return Ok(vec![]); @@ -397,25 +512,30 @@ async fn fetch_fault_alerts( WHERE comp.status = 2 ORDER BY comp.updated_at DESC LIMIT 10"# + .to_string() } else { - r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet, + format!( + r#"SELECT IFNULL(p.name, '') AS project, IFNULL(cab.name, cab.abstract_id) AS cabinet, comp.channel_id FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id JOIN cabinets cab ON cb.cabinet_id = cab.id INNER JOIN projects p ON cab.project_id = p.id - WHERE comp.status = 2 AND p.organization_id = ? + WHERE comp.status = 2 AND p.organization_id = ?{} ORDER BY comp.updated_at DESC - LIMIT 10"# + LIMIT 10"#, + scope.cond + ) }; let rows: Vec<(String, String, i8)> = if is_super { - sqlx::query_as(base_sql).fetch_all(pool).await? + sqlx::query_as(&base_sql).fetch_all(pool).await? } else { - sqlx::query_as(base_sql) - .bind(org_id.unwrap()) - .fetch_all(pool) - .await? + let mut query = sqlx::query_as::<_, (String, String, i8)>(&base_sql).bind(org_id.unwrap()); + for bind in scope.binds { + query = query.bind(bind); + } + query.fetch_all(pool).await? }; Ok(rows @@ -472,6 +592,9 @@ async fn fetch_project_summaries( } /// 获取用户可见的项目(优先使用查询参数 org_id) +/// +/// **数据范围(项目级)**:组织隔离给出"哪些组织的项目",再按 `CurrentUser::scope` +/// 过滤掉范围外的项目(未配置 = 不限制;总管理员豁免)。 async fn fetch_visible_projects( pool: &sqlx::MySqlPool, user: &CurrentUser, @@ -498,7 +621,14 @@ async fn fetch_visible_projects( vec![] }; - Ok(projects) + let scope = user.effective_scope(); + if scope.is_unrestricted() { + return Ok(projects); + } + Ok(projects + .into_iter() + .filter(|p| scope.allows_project(Some(p.id))) + .collect()) } /// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数 @@ -536,28 +666,123 @@ async fn fetch_project_summary( })) } +// --------------------------------------------------------------------------- +// 可见组织(小程序专用) +// --------------------------------------------------------------------------- + +/// 可见组织行(**只暴露必要字段**:`id` / `name`) +/// +/// 字段名与 `GET /api/organizations`(管理端 `OrganizationRow`)保持一致,便于前端复用同一类型; +/// 但**故意不带** `parent_id` / `disabled` 等管理端字段 —— 小程序只需要渲染组织名。 +#[derive(Debug, Serialize, sqlx::FromRow)] +struct VisibleOrganizationRow { + id: i64, + name: String, +} + +/// 工厂侧(总管理员)可见全部组织 +const VISIBLE_ORGS_ALL_SQL: &str = "SELECT id, name FROM organizations ORDER BY id"; +/// 客户侧用户只可见自己所属组织(`users.organization_id`) +const VISIBLE_ORGS_BY_ID_SQL: &str = "SELECT id, name FROM organizations WHERE id = ?"; + +/// 可见组织范围(由 [`visible_org_scope`] 依据角色算出;纯数据,便于单测) +#[derive(Debug, PartialEq, Eq)] +enum VisibleOrgScope { + /// 工厂/总管理员:全部组织 + All, + /// 组织管理员/操作员:仅本组织 + Own(i64), + /// 无组织归属:空集(**返回空数组,不报错**) + Empty, +} + +/// 角色等级 + 所属组织 → 可见组织范围(纯函数,组织隔离的唯一判定点) +/// +/// **组织可见性口径(全系统统一,见 AGENTS.md「组织架构」):** +/// - `role_level >= 2`(工厂/总管理员)→ 全部组织; +/// - 其他(组织管理员 `1` / 操作员 `0`)→ 仅 `users.organization_id` 指向的那一个组织; +/// - `organization_id` 为 NULL → 空集。 +fn visible_org_scope(role_level: i32, organization_id: Option) -> VisibleOrgScope { + if role_level >= 2 { + return VisibleOrgScope::All; + } + match organization_id { + Some(org_id) => VisibleOrgScope::Own(org_id), + None => VisibleOrgScope::Empty, + } +} + +/// GET /api/my-organizations — 当前登录用户**可见**的组织列表(小程序「选择组织」用) +/// +/// **权限:仅需登录**(与 `GET /api/dashboard` 一致,内部按角色做组织隔离), +/// **不校验 `org:view`** —— 操作员(`normal_user`)的授权清单里没有该权限码 +/// (`db/migrate.rs::seed_roles`),沿用管理端接口会 403、组织弹层永远为空。 +/// +/// **隔离(核心安全边界)**:响应只包含当前用户有权看到的组织 —— +/// 工厂(`role_level >= 2`)全量;客户侧(组织管理员/操作员)**只有自己所属组织**, +/// 绝不把全量组织泄露给客户角色。 +/// +/// 管理端的 `GET /api/organizations`(`org:view` + 全量)语义保持原样,不做任何改动。 +pub async fn list_my_organizations( + user: CurrentUser, + State(state): State, +) -> Result, AppError> { + let pool = &state.mysql; + + let rows: Vec = match visible_org_scope(user.role_level, user.organization_id) + { + // 工厂:全部组织 + VisibleOrgScope::All => { + sqlx::query_as::<_, VisibleOrganizationRow>(VISIBLE_ORGS_ALL_SQL) + .fetch_all(pool) + .await? + } + // 客户侧:**唯一的组织过滤点** —— `WHERE id = ` + VisibleOrgScope::Own(org_id) => { + sqlx::query_as::<_, VisibleOrganizationRow>(VISIBLE_ORGS_BY_ID_SQL) + .bind(org_id) + .fetch_all(pool) + .await? + } + // 无组织归属:空数组(不是错误) + VisibleOrgScope::Empty => Vec::new(), + }; + + // 包装格式与 `GET /api/organizations` 完全一致(`data` 直接是组织数组,字段 id/name) + Ok(Json(json!({ + "code": 0, + "data": rows, + "message": "ok" + }))) +} + // --------------------------------------------------------------------------- // 设备列表 // --------------------------------------------------------------------------- -// --- 设备列表 SQL(由原函数体逐字搬移;SELECT 字段列表 / 子查询 / CAST 均未改动) --- +// --- 设备列表 SQL(由原函数体逐字搬移;`channel_count` / `idle_count` / `charging_count` 子查询与 CAST 未改动) --- +// +// ⚠️ **口径变更(产品决定,本次)**:`compartments.status` 只表达**占用**语义 +// (`0 空闲 / 1 充电中` —— 平台只写这两个值,全仓没有任何写入点会写 2/3), +// "充满 / 故障"**不落库**。因此这 5 条分支 SQL(按项目 / 项目+组织 / 组织 / 全部 / 本组织) +// 里的 `full_count` / `fault_count` 子查询(`comp.status = 2 / 3`)已**整体删除** +// —— 它们在 DB 里恒为 0,留着只是白白多跑两次子查询;改为由通道实时状态派生: +// [`load_cabinet_realtime`] 批量读通道状态 → `cabinet_tree::derive_channel_counts` +// → [`build_cabinet_list`] 写回响应。**5 条分支共用同一条派生链路,一处在、处处在。** +// 响应字段名 `full_count` / `fault_count`(以及 `idle_count` / `charging_count`)**保持不变**。 /// 项目过滤 + 总管理员(不带组织条件) const CABINETS_BY_PROJECT_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count + (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count FROM cabinets c WHERE c.project_id = ?"#; /// 项目过滤 + 企业管理员(额外校验项目归属组织) const CABINETS_BY_PROJECT_AND_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count + (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count FROM cabinets c INNER JOIN projects p ON c.project_id = p.id WHERE c.project_id = ? AND p.organization_id = ?"#; @@ -566,9 +791,7 @@ const CABINETS_BY_PROJECT_AND_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.n const CABINETS_BY_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count + (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count FROM cabinets c INNER JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"#; @@ -577,18 +800,14 @@ const CABINETS_BY_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.statu const CABINETS_ALL_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count + (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count FROM cabinets c"#; /// 企业管理员只看本组织 const CABINETS_BY_USER_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, - (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count + (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count FROM cabinets c INNER JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"#; @@ -605,6 +824,20 @@ struct CabinetRealtimeMaps { dc: HashMap>, ac: HashMap>, temp: HashMap>, + /// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开) + /// + /// 取不到(Redis 缺字段 / 设备离线 / Redis 不可用)→ `None` → 响应 `null`, + /// **绝不落 `"00"`**(`"00"` 是"全部正常"的有效值)。位域解析在前端纯函数里做。 + status1: HashMap>, + /// **柜级**柜体状态2(协议 §3.8,bit0 声光报警 / bit1 柜门打开超时) + status2: HashMap>, + /// 柜子ID → 通道派生四计数 `(idle, charging, full, fault)` + /// + /// 只有 `.2`(充满)/ `.3`(故障)会被写进响应:`idle_count` / `charging_count` + /// 用 DB 的占用值(`compartments.status` 0/1),**派生值不覆盖它们**。 + /// 表里没有的柜子(无通道 / Redis 不可用)→ 调用方按 `(0,0,0,0)` 处理, + /// **离线无法判定,充满数与故障数一律记 0**。 + counts: HashMap, } /// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离) @@ -637,28 +870,42 @@ pub async fn get_cabinets( } /// 按「项目 / 角色」优先级查询用户可见的柜子行(分支顺序与原实现逐条对应) +/// +/// 最后统一叠加**数据范围(项目级)**的内存过滤:未配置范围时原样返回(与今天完全一致)。 async fn fetch_cabinet_rows( pool: &sqlx::MySqlPool, user: &CurrentUser, project_id: Option, org_id: Option, ) -> Result, AppError> { - if let Some(pid) = project_id { + let rows = if let Some(pid) = project_id { // 按项目过滤(同时验证组织权限) - fetch_cabinet_rows_by_project(pool, user, pid).await + fetch_cabinet_rows_by_project(pool, user, pid).await? } else if user.role_level >= 2 { // 总管理员:按组织过滤或看全部 - fetch_cabinet_rows_for_admin(pool, org_id).await + fetch_cabinet_rows_for_admin(pool, org_id).await? } else if let Some(org_id) = user.organization_id { // 企业管理员只看本组织 - let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_USER_ORG_SQL) + sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_USER_ORG_SQL) .bind(org_id) .fetch_all(pool) - .await?; - Ok(rows) + .await? } else { - Ok(vec![]) + vec![] + }; + + Ok(retain_in_scope(rows, user)) +} + +/// 数据范围(项目级)内存过滤:未配置范围 / 总管理员时原样返回 +fn retain_in_scope(rows: Vec, user: &CurrentUser) -> Vec { + let scope = user.effective_scope(); + if scope.is_unrestricted() { + return rows; } + rows.into_iter() + .filter(|row| scope.allows_project(row.project_id)) + .collect() } /// 项目过滤分支:总管理员不带组织条件,企业管理员额外校验项目归属组织 @@ -704,10 +951,23 @@ async fn fetch_cabinet_rows_for_admin( } } -/// 从 Redis 批量读取柜子实时状态(Pipeline HMGET,1 次网络往返) -/// -/// 注意:pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐, +/// HMGET 结果里的柜级 hex 位域取值(协议 §3.8):`None` / 空串 / 纯空白都算"无数据" +/// (→ 响应 `null`),**绝不落 `"00"`**(`"00"` 是"全部正常"的有效值,与"没数据"必须区分)。 +/// 非空值**原样返回**,不解析、不改写大小写 —— 位域解析统一由前端纯函数完成。 +fn pick_non_empty_hex(raw: Option<&Option>) -> Option { + raw.and_then(|v| v.as_ref()) + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +/// 从 Redis 批量读取柜子实时状态(Pipeline HMGET,1 次网络往返)/// +/// 注意:柜级 pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐, /// 与重构前的行为完全一致(这是既有约定,不做修正)。 +/// +/// 通道级派生计数([`CabinetRealtimeMaps::counts`])单独走**自己的一条 pipeline**, +/// 键由 DB 的「板号 + 通道号」拼出(`cabinet_tree::fetch_channel_keys`), +/// 因此不受上面那条「按行下标对齐」的历史约定影响,5 条分支 SQL 全部共用本函数。 async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> CabinetRealtimeMaps { let mut maps = CabinetRealtimeMaps::default(); if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { @@ -717,7 +977,15 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin for r in rows { if let Some(ref imei) = r.imei { let key = format!("device:{imei}"); - pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]); + // ⚠️ 字段顺序与下面的下标解析一一对应(新增字段只能追加在末尾) + pipe.cmd("HMGET").arg(&key).arg(&[ + "rssi", + "pow_fail_dc", + "pow_fail_ac", + "temp", + "cabinet_status1", + "cabinet_status2", + ]); } } if !rows.is_empty() { @@ -735,12 +1003,27 @@ async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> Cabin maps.dc.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1")); maps.ac.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1")); maps.temp.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); + // 柜级状态位:原样透出 hex;空串/缺失 → None(响应 null,不落 "00") + maps.status1.insert(imei.clone(), pick_non_empty_hex(vals.get(4))); + maps.status2.insert(imei.clone(), pick_non_empty_hex(vals.get(5))); } } } } } } + + // 通道级:按柜派生「充满数 / 故障数」(DB 里 `compartments.status` 只写 0/1,给不出这两个) + let cabinet_ids: Vec = rows.iter().map(|r| r.id).collect(); + let entries = match super::cabinet_tree::fetch_channel_keys(&state.mysql, &cabinet_ids).await { + Ok(entries) => entries, + Err(e) => { + tracing::error!("[h5] 通道 key 查询失败,充满/故障计数按 0 计 err={}", e); + Vec::new() + } + }; + maps.counts = super::cabinet_tree::derive_counts_by_cabinet(state, &entries).await; + maps } @@ -769,6 +1052,11 @@ async fn load_org_disabled_map( } /// 行结构体 + 实时状态 → 响应 JSON 列表(字段名与语义与原实现逐字一致) +/// +/// `full_count` / `fault_count` 的**取值来源变了**(字段名不变):不再来自 SQL +/// (`compartments.status = 2/3` 在 DB 里永远不成立),改为由通道实时状态派生 +/// ([`CabinetRealtimeMaps::counts`],规则见 `cabinet_tree::derive_channel_counts`)。 +/// 派生不到数据(设备离线 / 无该通道键 / Redis 不可用)→ **按 0 计,不猜测**。 fn build_cabinet_list( rows: Vec, realtime: &CabinetRealtimeMaps, @@ -781,7 +1069,13 @@ fn build_cabinet_list( let pow_fail_dc = imei.as_ref().and_then(|i| realtime.dc.get(i).copied()).flatten(); let pow_fail_ac = imei.as_ref().and_then(|i| realtime.ac.get(i).copied()).flatten(); let temp = imei.as_ref().and_then(|i| realtime.temp.get(i).copied()).flatten(); + // 柜级状态位(协议 §3.8):原样透出 2 位 hex;取不到 → null(不落 "00") + let status1 = imei.as_ref().and_then(|i| realtime.status1.get(i).cloned()).flatten(); + let status2 = imei.as_ref().and_then(|i| realtime.status2.get(i).cloned()).flatten(); let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false); + // `ChannelCounts = (idle, charging, full, fault)`:只取派生出的充满/故障 + let (_, _, full_count, fault_count) = + realtime.counts.get(&r.id).copied().unwrap_or((0, 0, 0, 0)); json!({ "id": r.id, "abstract_id": r.abstract_id, @@ -796,12 +1090,15 @@ fn build_cabinet_list( "channel_count": r.channel_count, "idle_count": r.idle_count, "charging_count": r.charging_count, - "full_count": r.full_count, - "fault_count": r.fault_count, + "full_count": full_count, + "fault_count": fault_count, "rssi": rssi, "pow_fail_dc": pow_fail_dc, "pow_fail_ac": pow_fail_ac, "temp": temp, + // **柜级**状态位(协议 §3.8):原样 hex 字符串,取不到为 null + "status1": status1, + "status2": status2, "org_disabled": org_disabled, }) }) @@ -942,12 +1239,19 @@ pub async fn get_compartment_detail( .fetch_all(pool) .await?; + // 展示状态:**有通道实时数据时按派生值**给出 `0空闲 / 1充电中 / 2充满 / 3故障` + // (优先级 fault > full > charging > idle,与 PC/小程序三处映射口径一致); + // **拿不到数据时回落到 DB 的占用值**(0/1)—— 离线无法判定,不猜测。 + // 字段名与类型不变(前端 `data.status` 直接消费),只是取值来源变了。 + let flags = derive_channel_flags(realtime.batt_status.as_deref(), realtime.pow_status.as_deref()); + let display_status = if flags.has_data { flags.status_code() } else { status }; + Ok(Json(json!({ "code": 0, "data": { "id": comp_id, "channel_id": channel_id, - "status": status, + "status": display_status, "cabin_board_id": cabin_board_id, // 以下实时字段全部取自设备上报真实写入的 Redis key; // 取不到时返回 null(**不落 0**),避免把"无数据"伪装成 0 @@ -1800,10 +2104,9 @@ pub async fn upload_feedback_image( if ext.is_empty() { ext = ".jpg".to_string(); } - let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; - if !allowed.contains(&ext.as_str()) { - return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into())); - } + // 扩展名白名单 + 文件魔数嗅探(拒绝 .svg/.htm 等可脚本化类型,防存储型 XSS) + // 返回值是按内容嗅探出的规范扩展名;落盘后缀与真实内容一致 + let ext = super::upload_guard::validate_image_upload(&data, &ext)?; let dir = std::path::Path::new(FEEDBACK_IMG_DIR); std::fs::create_dir_all(dir) @@ -1869,10 +2172,10 @@ pub async fn update_feedback_status( }))) } -/// 验证用户是否有权访问指定柜子(组织数据隔离) +/// 验证用户是否有权访问指定柜子(组织数据隔离 + 数据范围) /// -/// 总管理员(role_level >= 2)可访问所有柜子; -/// 企业管理员只能访问本组织下项目关联的柜子; +/// 总管理员(role_level >= 2)可访问所有柜子(数据范围同样豁免); +/// 企业管理员只能访问本组织下项目关联的柜子,**且柜子所属项目必须在其数据范围内**; /// 无组织关联的用户无权访问任何柜子。 async fn verify_cabinet_access( user: &CurrentUser, @@ -1888,16 +2191,19 @@ async fn verify_cabinet_access( .organization_id .ok_or_else(|| AppError::Forbidden("无组织关联,无权访问设备".into()))?; - // 通过 cabinet → project → organization 链路验证归属 - let access: Option<(i64,)> = sqlx::query_as( + // 通过 cabinet → project → organization 链路验证归属,并叠加数据范围(项目级) + let (scope_cond, scope_binds) = user.effective_scope().project_condition("p.id"); + let sql = format!( r#"SELECT cab.id FROM cabinets cab INNER JOIN projects p ON cab.project_id = p.id - WHERE cab.id = ? AND p.organization_id = ?"#, - ) - .bind(cabinet_id) - .bind(org_id) - .fetch_optional(pool) - .await?; + WHERE cab.id = ? AND p.organization_id = ?{}"#, + scope_cond + ); + let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(cabinet_id).bind(org_id); + for bind in &scope_binds { + query = query.bind(bind); + } + let access: Option<(i64,)> = query.fetch_optional(pool).await?; if access.is_none() { return Err(AppError::Forbidden("无权访问该设备".into())); @@ -1914,6 +2220,27 @@ async fn verify_cabinet_access( mod tests { use super::*; + /// 小程序设备列表的柜级 hex 位域取值(协议 §3.8): + /// `"00"`(全正常)必须原样保留;缺失 / 空串 / 纯空白必须是 `None`(响应 `null`)。 + #[test] + fn test_h5_list_pick_non_empty_hex_keeps_00_but_drops_missing() { + assert_eq!( + pick_non_empty_hex(Some(&Some("00".into()))).as_deref(), + Some("00"), + "\"00\" 是「全部正常」的有效值,与「无数据」必须区分" + ); + assert_eq!( + pick_non_empty_hex(Some(&Some("10".into()))).as_deref(), + Some("10"), + "柜门打开位原样透出" + ); + assert_eq!(pick_non_empty_hex(Some(&Some("01".into()))).as_deref(), Some("01")); + assert_eq!(pick_non_empty_hex(Some(&Some(String::new()))), None); + assert_eq!(pick_non_empty_hex(Some(&Some(" ".into()))), None); + assert_eq!(pick_non_empty_hex(Some(&None)), None); + assert_eq!(pick_non_empty_hex(None), None); + } + /// 通道级 HMGET 六字段(batt_status/pow_status/soc/voltage/current/temp)解析 #[test] fn test_parse_compartment_realtime_reads_all_fields() { @@ -1994,4 +2321,44 @@ mod tests { assert_eq!(COMPARTMENT_REALTIME_FIELDS[4], "current"); assert_eq!(COMPARTMENT_REALTIME_FIELDS[5], "temp"); } + + // ----------------------------------------------------------------------- + // 可见组织范围(组织隔离) + // ----------------------------------------------------------------------- + + /// 工厂/总管理员:可见全部组织(即便自身也挂着 organization_id,也不降级为本组织) + #[test] + fn test_visible_org_scope_super_admin_sees_all() { + assert_eq!(visible_org_scope(2, None), VisibleOrgScope::All); + assert_eq!(visible_org_scope(2, Some(7)), VisibleOrgScope::All); + } + + /// 操作员(role_level=0):只可见自己所属组织 + #[test] + fn test_visible_org_scope_operator_sees_own_org_only() { + assert_eq!(visible_org_scope(0, Some(42)), VisibleOrgScope::Own(42)); + } + + /// 组织管理员(role_level=1):同样只可见自己所属组织(不允许跨组织) + #[test] + fn test_visible_org_scope_org_admin_sees_own_org_only() { + assert_eq!(visible_org_scope(1, Some(42)), VisibleOrgScope::Own(42)); + } + + /// 客户侧无组织归属:空集(**不能退化成"全部"**,否则越权) + #[test] + fn test_visible_org_scope_org_user_without_org_is_empty() { + assert_eq!(visible_org_scope(0, None), VisibleOrgScope::Empty); + assert_eq!(visible_org_scope(1, None), VisibleOrgScope::Empty); + } + + /// 客户侧 SQL 必须带组织过滤条件(防止将来被改成全量查询导致越权) + #[test] + fn test_visible_orgs_own_sql_filters_by_id() { + assert!( + VISIBLE_ORGS_BY_ID_SQL.contains("WHERE id = ?"), + "客户侧可见组织查询必须按 organization_id 过滤" + ); + assert_eq!(visible_org_scope(0, Some(1)), VisibleOrgScope::Own(1)); + } } diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index d30a4d2..4375af0 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -3,11 +3,12 @@ //! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。 //! 权限校验在各路由处理器内部通过 `check_permission` 完成。 -use axum::http::Method; +use axum::http::request::Parts; +use axum::http::{header, HeaderValue, Method}; use axum::middleware; use axum::routing::{get, post, put}; use axum::Router; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::services::ServeDir; use crate::commands::AppState; @@ -28,12 +29,37 @@ mod operation_logs; mod organizations; mod projects; mod roles; +mod upload_guard; mod users; pub(crate) mod scope_guard; mod users_perm; mod wechat; mod maps; +/// 允许跨域的前端来源白名单(精确匹配):生产域名 + 测试域名 +/// +/// 说明:微信小程序请求不受同源策略约束(不是浏览器 CORS 模型), +/// PC 管理端与测试域名同域,故只需覆盖这两个来源。 +const CORS_ALLOWED_ORIGINS: [&str; 2] = [ + "https://app.anzhizhichong.com", + "https://test.anzhizhichong.com", +]; + +/// CORS 来源是否在白名单内 +/// +/// - 生产/测试域名:精确匹配(拒绝任何其他来源) +/// - 本地开发:`http://localhost:{port}` / `http://127.0.0.1:{port}`(vite dev 端口不固定) +pub(crate) fn is_allowed_origin(origin: &str) -> bool { + if CORS_ALLOWED_ORIGINS.contains(&origin) { + return true; + } + ["http://localhost:", "http://127.0.0.1:"].iter().any(|prefix| { + origin + .strip_prefix(prefix) + .is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())) + }) +} + /// 认证与账户相关路由(需登录) fn auth_routes() -> Router { Router::::new() @@ -146,6 +172,7 @@ fn user_routes() -> Router { fn h5_routes() -> Router { Router::::new() .route("/api/dashboard", get(h5::get_dashboard)) + .route("/api/my-organizations", get(h5::list_my_organizations)) .route("/api/cabinets", get(h5::get_cabinets)) .route("/api/compartments/{id}", get(h5::get_compartment_detail)) .route("/api/charge/start", post(h5::start_charge)) @@ -200,9 +227,46 @@ pub fn build(state: AppState) -> Router { public_routes() .merge(protected) - .layer(CorsLayer::new() - .allow_origin(Any) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) - .allow_headers([axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE])) + .layer( + // CORS 收敛:仅回显白名单内的 Origin(不再 allow_origin(Any)), + // 非白名单来源不返回任何 CORS 允许头(浏览器侧自然拦截)。 + // 不开 allow_credentials(token 走 Authorization 头,无需 cookie 凭证)。 + CorsLayer::new() + .allow_origin(AllowOrigin::predicate( + |origin: &HeaderValue, _parts: &Parts| { + origin.to_str().map(is_allowed_origin).unwrap_or(false) + }, + )) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]), + ) .with_state(state) } + +#[cfg(test)] +mod tests { + use super::*; + + /// CORS 白名单:生产/测试域名与本地开发来源放行 + #[test] + fn test_is_allowed_origin_allows_whitelist() { + assert!(is_allowed_origin("https://app.anzhizhichong.com")); + assert!(is_allowed_origin("https://test.anzhizhichong.com")); + assert!(is_allowed_origin("http://localhost:5173")); + assert!(is_allowed_origin("http://localhost:3000")); + assert!(is_allowed_origin("http://127.0.0.1:5173")); + } + + /// CORS 白名单:任意其他来源、跨域 http、欺骗性后缀一律拒绝 + #[test] + fn test_is_allowed_origin_rejects_others() { + assert!(!is_allowed_origin("https://evil.com")); + assert!(!is_allowed_origin("http://app.anzhizhichong.com")); + assert!(!is_allowed_origin("https://app.anzhizhichong.com.evil.com")); + assert!(!is_allowed_origin("null")); + assert!(!is_allowed_origin("")); + assert!(!is_allowed_origin("http://localhost.evil.com:5173")); + assert!(!is_allowed_origin("http://localhost:abc")); + assert!(!is_allowed_origin("http://localhost:")); + } +} diff --git a/software/api-server/src/routes/operation_logs.rs b/software/api-server/src/routes/operation_logs.rs index 12333a9..190bf75 100644 --- a/software/api-server/src/routes/operation_logs.rs +++ b/software/api-server/src/routes/operation_logs.rs @@ -68,6 +68,11 @@ fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec, pub pow_fail_dc: Option, pub pow_fail_ac: Option, + /// **柜级**柜体状态1(协议 §3.8,2 位小写 hex 原样透出;`"10"` = 柜门打开) + /// + /// 数据源 Redis `device:{imei}.cabinet_status1`;取不到为 `null` + /// (**绝不落 `"00"`** —— `"00"` 是"全部正常"的有效值)。位域解析在前端纯函数里做。 + pub status1: Option, + /// **柜级**柜体状态2(协议 §3.8,2 位小写 hex 原样透出;bit0 = 声光报警 / bit1 = 柜门打开超时) + pub status2: Option, pub is_online: Option, /// 所属组织是否被禁用(甲方禁用客户组织) pub org_disabled: Option, diff --git a/software/api-server/src/routes/projects.rs b/software/api-server/src/routes/projects.rs index 946d8a2..d08b51a 100644 --- a/software/api-server/src/routes/projects.rs +++ b/software/api-server/src/routes/projects.rs @@ -25,6 +25,8 @@ pub struct ProjectBody { } /// GET /api/organizations/:org_id/projects — 按组织查询项目列表 +/// +/// **数据范围**:配了项目范围的用户只看到范围内项目(未配置 = 不限制;总管理员豁免)。 pub async fn list_projects( user: CurrentUser, State(state): State, @@ -34,12 +36,16 @@ pub async fn list_projects( super::scope_guard::verify_org_ownership(&user, org_id)?; let db = &state.mysql; - let rows = sqlx::query_as::<_, ProjectRow>( - r#"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects WHERE organization_id = ? ORDER BY id"#, - ) - .bind(org_id) - .fetch_all(db) - .await?; + let (scope_cond, scope_binds) = user.effective_scope().project_condition("id"); + let sql = format!( + r#"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects WHERE organization_id = ?{} ORDER BY id"#, + scope_cond + ); + let mut query = sqlx::query_as::<_, ProjectRow>(&sql).bind(org_id); + for bind in &scope_binds { + query = query.bind(bind); + } + let rows = query.fetch_all(db).await?; Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } diff --git a/software/api-server/src/routes/scope_guard.rs b/software/api-server/src/routes/scope_guard.rs index f3a1995..f569913 100644 --- a/software/api-server/src/routes/scope_guard.rs +++ b/software/api-server/src/routes/scope_guard.rs @@ -1,40 +1,75 @@ -//! 组织级数据隔离校验 — 确保用户只能访问其所属组织的资源。 +//! 数据访问归属校验 — 确保用户只能访问「本组织 **且** 在其数据范围内」的资源。 //! -//! 总管理员(role_level=2)跳过所有组织归属校验。 -//! 企业管理员(role_level=1)和操作员(role_level=0)只能访问本组织资源。 +//! 两道闸门,**必须同时通过**(取交集,绝不是取并集): +//! 1. **组织归属**:`role_level >= 2`(总管理员)跳过;否则资源必须属于 +//! `users.organization_id` 指向的组织; +//! 2. **数据范围**(`user_scopes`,见 [`CurrentUser::scope`]):总管理员豁免; +//! 未配置 scope 的用户不额外收窄(与今天完全一致);配了 scope 的用户, +//! 资源所属项目必须落在范围内,否则 `Forbidden`。 +//! +//! 为什么两道闸门写在**同一个 SQL** 里:单资源校验是详情/写操作的必经路径, +//! 每次多一次往返不划算;而且分两条查询写极易漏掉一条(漏掉就是越权)。 use sqlx::MySqlPool; use crate::error::AppError; -use crate::middleware::auth::CurrentUser; +use crate::middleware::auth::{CurrentUser, UserScope}; -/// 校验当前用户是否有权访问指定柜子。 +/// 柜子归属校验 SQL(含数据范围收窄片段) +/// +/// 纯函数,便于单测断言"配了 scope 就必须带上项目收窄"。 +fn cabinet_access_sql(scope: &UserScope) -> String { + let (scope_cond, _) = scope.project_condition("c.project_id"); + format!( + "SELECT EXISTS(SELECT 1 FROM cabinets c \ + JOIN projects p ON c.project_id = p.id \ + WHERE c.id = ? AND p.organization_id = ?{})", + scope_cond + ) +} + +/// 项目归属校验 SQL(含数据范围收窄片段) +fn project_access_sql(scope: &UserScope) -> String { + let (scope_cond, _) = scope.project_condition("p.id"); + format!( + "SELECT EXISTS(SELECT 1 FROM projects p WHERE p.id = ? AND p.organization_id = ?{})", + scope_cond + ) +} + +/// 校验当前用户是否有权访问指定柜子(组织归属 + 数据范围) pub async fn verify_cabinet_ownership( pool: &MySqlPool, user: &CurrentUser, cabinet_id: i64, ) -> Result<(), AppError> { + // 总管理员豁免组织与范围两道校验 if user.role_level >= 2 { return Ok(()); } let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?; - let has_access: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM cabinets c \ - JOIN projects p ON c.project_id = p.id \ - WHERE c.id = ? AND p.organization_id = ?)", - ) - .bind(cabinet_id) - .bind(org_id) - .fetch_one(pool) - .await - .map_err(|_| AppError::Forbidden("无权访问该资源".into()))?; + // `effective_scope()`:总管理员恒为"不限制"(这里虽已提前 return,仍显式兜底一道) + let scope = user.effective_scope(); + let (_, scope_binds) = scope.project_condition("c.project_id"); + let sql = cabinet_access_sql(&scope); + let mut query = sqlx::query_scalar::<_, bool>(&sql) + .bind(cabinet_id) + .bind(org_id); + for bind in &scope_binds { + query = query.bind(bind); + } + // 失败安全:任何查询异常都按"无权访问"处理(绝不放行) + let has_access: bool = query + .fetch_one(pool) + .await + .map_err(|_| AppError::Forbidden("无权访问该资源".into()))?; if !has_access { return Err(AppError::Forbidden("无权访问该资源".into())); } Ok(()) } -/// 校验当前用户是否有权访问指定项目。 +/// 校验当前用户是否有权访问指定项目(组织归属 + 数据范围) pub async fn verify_project_ownership( pool: &MySqlPool, user: &CurrentUser, @@ -44,14 +79,19 @@ pub async fn verify_project_ownership( return Ok(()); } let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?; - let has_access: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM projects WHERE id = ? AND organization_id = ?)", - ) - .bind(project_id) - .bind(org_id) - .fetch_one(pool) - .await - .map_err(|_| AppError::Forbidden("无权访问该资源".into()))?; + let scope = user.effective_scope(); + let (_, scope_binds) = scope.project_condition("p.id"); + let sql = project_access_sql(&scope); + let mut query = sqlx::query_scalar::<_, bool>(&sql) + .bind(project_id) + .bind(org_id); + for bind in &scope_binds { + query = query.bind(bind); + } + let has_access: bool = query + .fetch_one(pool) + .await + .map_err(|_| AppError::Forbidden("无权访问该资源".into()))?; if !has_access { return Err(AppError::Forbidden("无权访问该资源".into())); } @@ -59,6 +99,10 @@ pub async fn verify_project_ownership( } /// 校验当前用户是否有权访问指定组织。 +/// +/// **数据范围不在这里收窄**:组织级 scope 只允许配成本人所属组织(写入侧强校验), +/// 因此"能否打开这个组织"与 scope 无关;组织**内部的列表**由各自的列表接口 +/// (如 `GET /api/organizations/{id}/projects`)按项目范围收窄,避免这里返回半截数据。 pub fn verify_org_ownership(user: &CurrentUser, org_id: i64) -> Result<(), AppError> { if user.role_level >= 2 { return Ok(()); @@ -67,4 +111,64 @@ pub fn verify_org_ownership(user: &CurrentUser, org_id: i64) -> Result<(), AppEr return Err(AppError::Forbidden("无权访问该资源".into())); } Ok(()) -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// 单元测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// 未配置 scope ⇒ 归属校验 SQL 不得出现任何项目收窄(向后兼容) + #[test] + fn test_access_sql_without_scope_has_no_narrowing() { + let scope = UserScope::default(); + let cabinet_sql = cabinet_access_sql(&scope); + let project_sql = project_access_sql(&scope); + assert!(!cabinet_sql.contains("project_id IN"), "未配置 scope 不得收窄:{}", cabinet_sql); + assert!(!project_sql.contains("p.id IN"), "未配置 scope 不得收窄:{}", project_sql); + // 组织归属条件必须仍在(这是安全边界,任何情况下都不能少) + assert!(cabinet_sql.contains("p.organization_id = ?")); + assert!(project_sql.contains("p.organization_id = ?")); + } + + /// 配置了项目范围 ⇒ 归属校验 SQL 必须叠加项目收窄,且占位符数量与范围条数一致 + #[test] + fn test_access_sql_with_scope_narrows_by_project() { + let scope = UserScope { project_ids: vec![3, 5, 8] }; + let cabinet_sql = cabinet_access_sql(&scope); + let project_sql = project_access_sql(&scope); + + assert!( + cabinet_sql.contains("c.project_id IN (?,?,?)"), + "柜子归属校验必须叠加项目范围:{}", + cabinet_sql + ); + assert!( + project_sql.contains("p.id IN (?,?,?)"), + "项目归属校验必须叠加项目范围:{}", + project_sql + ); + } + + /// 组织归属校验(同步函数):总管理员豁免、本组织放行、跨组织拒绝 + #[test] + fn test_verify_org_ownership_rules() { + use std::collections::HashSet; + let make = |role_level: i32, org: Option| CurrentUser { + user_id: 1, + phone: "13800138000".into(), + permissions: HashSet::new(), + role_level, + organization_id: org, + scope: UserScope::default(), + }; + + assert!(verify_org_ownership(&make(2, None), 999).is_ok(), "总管理员豁免"); + assert!(verify_org_ownership(&make(1, Some(7)), 7).is_ok(), "本组织放行"); + assert!(verify_org_ownership(&make(1, Some(7)), 8).is_err(), "跨组织必须拒绝"); + assert!(verify_org_ownership(&make(0, None), 7).is_err(), "无组织必须拒绝"); + } +} diff --git a/software/api-server/src/routes/upload_guard.rs b/software/api-server/src/routes/upload_guard.rs new file mode 100644 index 0000000..0b56c43 --- /dev/null +++ b/software/api-server/src/routes/upload_guard.rs @@ -0,0 +1,132 @@ +//! 上传图片安全校验 — 扩展名白名单 + 文件魔数(文件头)嗅探 +//! +//! 背景:`/uploads` 由 `nest_service("/uploads", ServeDir::new("uploads"))` 免认证同源直出, +//! 若允许上传 `.svg`/`.html` 等可脚本化类型,任意登录用户即可上传带 ` - + + - @@ -105,27 +104,34 @@ const error = ref('') const actionLoading = ref('') const currentId = ref(0) +/** + * 通道状态口径:`0空闲 / 1充电中 / 2充满 / 3故障` + * + * 与后端派生规则(`api-server/src/routes/cabinet_tree.rs::derive_channel_flags`, + * 优先级 fault > full > charging > idle)、PC `web/.../cabinet.tsx::STATUS_MAP`、 + * `device-detail/index.vue::compClass` **三处完全一致**;颜色:空闲=灰 / 充电中=蓝 / 充满=绿 / 故障=红。 + */ const statusClass = computed(() => { if (!data.value) return 'idle' if (data.value.status === 1) return 'charging' - if (data.value.status === 2) return 'fault' - if (data.value.status === 3) return 'full' + if (data.value.status === 2) return 'full' + if (data.value.status === 3) return 'fault' return 'idle' }) const statusText = computed(() => { if (!data.value) return '' if (data.value.status === 0) return '空闲' if (data.value.status === 1) return '充电中' - if (data.value.status === 2) return '故障' - if (data.value.status === 3) return '充满' + if (data.value.status === 2) return '充满' + if (data.value.status === 3) return '故障' return '离线' }) const statusColor = computed(() => { if (!data.value) return '#94a3b8' - if (data.value.status === 1) return '#0d9488' - if (data.value.status === 2) return '#ef4444' - if (data.value.status === 3) return '#f59e0b' - return '#22c55e' + if (data.value.status === 1) return '#2563eb' + if (data.value.status === 2) return '#16a34a' + if (data.value.status === 3) return '#ef4444' + return '#94a3b8' }) function actionColor(type: string): string { if (type === 'primary') return '#fff' @@ -144,15 +150,21 @@ async function fetchDetail(id: number) { function goBack() { uni.navigateBack() } function confirmStop() { uni.showModal({ title: '停止充电', content: '确定要停止当前充电?', success: (res) => { if (res.confirm) handleAction('stop') } }) } -function confirmDoor() { uni.showModal({ title: '开门', content: '确定要打开仓门?', success: (res) => { if (res.confirm) handleAction('door') } }) } -async function handleAction(action: 'start' | 'stop' | 'door') { +/** + * 仓体级动作:**只有**开始/停止充电。 + * + * ⛔ 开门**不在这里**(原实现曾用 `/api/door/open` + 仓体 ID 开门):柜门是整柜共用的一扇, + * 协议 §4.2 L403 明确「没有"按仓体(舱门)开门"的指令」—— 开门的唯一入口是设备详情页的 + * 柜级按钮(`h5Api.openCabinet`)。 + * 另:仓体级状态位只有 `batt_status` / `pow_status`,**不展示任何"门"状态**。 + */ +async function handleAction(action: 'start' | 'stop') { if (!data.value || actionLoading.value) return actionLoading.value = action try { if (action === 'start') await h5Api.startCharge(data.value.id) - else if (action === 'stop') await h5Api.stopCharge(data.value.id) - else await h5Api.openDoor(data.value.id) + else await h5Api.stopCharge(data.value.id) fetchDetail(data.value.id) uni.showToast({ title: '操作成功', icon: 'success' }) } catch { uni.showToast({ title: '操作失败', icon: 'none' }) } @@ -173,17 +185,18 @@ async function handleAction(action: 'start' | 'stop' | 'door') { .status-card { display: flex; align-items: center; justify-content: space-between; } .status-info { display: flex; align-items: center; gap: 10px; } .status-ring { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } -.status-ring.charging { background: #f0fdfa; color: #0d9488; animation: ringPulse 1.5s ease-in-out infinite; } -.status-ring.idle { background: #f0fdf4; color: #22c55e; } +/* 状态配色:空闲=灰 / 充电中=蓝 / 充满=绿 / 故障=红(与 PC `STATUS_MAP`、device-detail 一致) */ +.status-ring.charging { background: #eff6ff; color: #2563eb; animation: ringPulse 1.5s ease-in-out infinite; } +.status-ring.idle { background: #f1f5f9; color: #475569; } .status-ring.fault { background: #fef2f2; color: #ef4444; } -.status-ring.full { background: #fffbeb; color: #f59e0b; } -@keyframes ringPulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(13,148,136,0.3); } 50% { box-shadow: 0 0 0 8px rgba(13,148,136,0); } } +.status-ring.full { background: #f0fdf4; color: #16a34a; } +@keyframes ringPulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(37,99,235,0.3); } 50% { box-shadow: 0 0 0 8px rgba(37,99,235,0); } } .status-name { font-size: 16px; font-weight: 600; color: #111827; display: block; } .status-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; display: block; } .status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; font-size: 11px; font-weight: 500; border-radius: 999px; } -.status-badge.charging { background: #f0fdfa; color: #0d9488; } -.status-badge.idle { background: #f0fdf4; color: #22c55e; } -.status-badge.full { background: #fffbeb; color: #f59e0b; } +.status-badge.charging { background: #eff6ff; color: #2563eb; } +.status-badge.idle { background: #f1f5f9; color: #475569; } +.status-badge.full { background: #f0fdf4; color: #16a34a; } .status-badge.fault { background: #fef2f2; color: #ef4444; } .status-badge::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } /* 电池状态 / 电源状态:两个独立指示(电池偏蓝绿系,电源偏灰橙系) */ diff --git a/software/h5-uniapp/src/pages/device-detail/index.vue b/software/h5-uniapp/src/pages/device-detail/index.vue index a5f8ed6..6833b3d 100644 --- a/software/h5-uniapp/src/pages/device-detail/index.vue +++ b/software/h5-uniapp/src/pages/device-detail/index.vue @@ -33,6 +33,40 @@ + + + + + 柜门 + {{ doorTag.label }} + 来自最近一次上报(约 60 秒一次),非实时 + + + 柜级告警 + + + + + + status1={{ data.status1 ?? 'null' }} / status2={{ data.status2 ?? 'null' }} + + + + + 下发的是「解锁」,门需要人到现场拉开 + + + @@ -74,7 +108,14 @@ import { ref, computed } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { h5Api, type CabinetDetail } from '../../api' import AppIcon from '../../components/AppIcon.vue' -import { battStatusOf, powStatusOf, powerFlagOf } from '../../utils/deviceStatus' +import { useAuthStore } from '../../store/auth' +import { + battStatusOf, + powStatusOf, + powerFlagOf, + cabinetDoorTag, + cabinetStatusTags, +} from '../../utils/deviceStatus' const statusBarHeight = ref(47) const navBarHeight = ref(44) @@ -94,8 +135,32 @@ const data = ref(null) const loading = ref(true) const error = ref('') const currentId = ref(0) +const doorLoading = ref(false) +const authStore = useAuthStore() const totalChannels = computed(() => data.value?.cabin_boards?.reduce((sum, b) => sum + b.compartments.length, 0) || 0) +/** + * 柜级柜门三态(协议 §4.2):`status1` bit4 = 柜门打开。 + * 无数据(`status1` 为 null / 非法)→ 「柜门未知」(**不会显示成"已关闭"**)。 + */ +const doorTag = computed(() => cabinetDoorTag(data.value?.status1)) +/** + * 柜级告警标签(协议 §3.8):`null` = 该维度无数据(与"确实无告警"的空数组区分开) + */ +const cabinetTags = computed(() => cabinetStatusTags(data.value?.status1, data.value?.status2)) + +/** + * 开门按钮可见性(协议 §4.2;后端权限码 `charge:open_door`) + * + * 小程序登录接口只下发 `role_level`、**不下发权限码列表**,所以前端沿用现有的 + * 登录态/角色判断方式(与 `home/index.vue` 里 `isSuperAdmin` 同源)做**可见性**控制: + * 权限种子里 `charge:open_door` 授予了全部三个默认角色 + * (`api-server/src/db/migrate.rs`:super_admin / org_admin / normal_user), + * 因此「已登录」即可见;**真正的权限判定在后端** + * (`routes/h5.rs::cabinet_open` → `check_permission("charge:open_door")`,无权限返回 403)。 + */ +const canOpenDoor = computed(() => authStore.isAuthenticated && !!authStore.user) + onLoad((query) => { if (query?.id) { currentId.value = Number(query.id); fetchDetail(currentId.value) } }) async function fetchDetail(id: number) { @@ -111,14 +176,55 @@ function signalClass(rssi?: number): string { if (rssi >= 40) return 'mid' return 'low' } +/** + * 通道状态 → 卡片配色 class + * + * 口径:`0空闲 / 1充电中 / 2充满 / 3故障`,与后端派生规则 + * (`api-server/src/routes/cabinet_tree.rs::derive_channel_flags`)、 + * PC `web/.../cabinet.tsx::STATUS_MAP`、`compartment-detail/index.vue::statusText` **三处完全一致**。 + * ⚠️ 仓体级**没有"禁用"档**(仓体级禁用并不存在),原先的 `4 → disabled` 已去除。 + */ function compClass(status: number): string { if (status === 1) return 'charging' - if (status === 2) return 'fault' - if (status === 3) return 'full' - if (status === 4) return 'disabled' + if (status === 2) return 'full' + if (status === 3) return 'fault' return 'idle' } function goBack() { uni.navigateBack({ delta: 1 }) } + +/** + * 解锁柜门(**柜级**指令,协议 §4.2):物理动作,必须二次确认 + * + * 文案要点:下发的是「解锁」,门要人拉开;平台没有「关门」指令; + * **与充电/订单没有任何联动**(协议 §5.7 / §7.2)。 + */ +function confirmOpenDoor() { + if (!data.value || doorLoading.value) return + uni.showModal({ + title: '解锁柜门(门要人拉开)', + content: '柜门是整柜共用的一扇:平台下发的是「解锁」,解锁后需要人到现场把门拉开,门不会自动打开,' + + '柜门状态最迟 60 秒内随设备上报更新(非实时)。平台没有「关门」指令,关门也由人操作。\n' + + '开门与充电/订单没有任何联动,不会影响正在进行的充电。', + confirmText: '解锁', + success: (res) => { if (res.confirm) handleOpenDoor() }, + }) +} + +/** 下发柜级 open(不带 id);**不触碰订单与充电**,也不立即刷新门状态(等 status_post) */ +async function handleOpenDoor() { + const cab = data.value + if (!cab || doorLoading.value) return + doorLoading.value = true + try { + await h5Api.openCabinet(cab.id) + uni.showToast({ title: '解锁指令已发送,请到现场拉开柜门', icon: 'none' }) + } catch (e: unknown) { + uni.showToast({ title: e instanceof Error ? e.message : '开门失败', icon: 'none' }) + } finally { + doorLoading.value = false + } +} + function copyText(text: string) { uni.setClipboardData({ data: text, success: () => uni.showToast({ title: '已复制', icon: 'none' }) }) } @@ -156,6 +262,21 @@ function openLocation() { /* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */ .pow-tag.unknown { background: #f1f5f9; color: #94a3b8; } .info-rows { border-top: 1px solid #f1f5f9; padding-top: 12px; } +/* 柜级状态位(协议 §3.8):柜门 + 柜级告警,**只在柜子层面展示** */ +.cab-card { margin: 12px 16px; background: #fff; border-radius: 12px; padding: 12px 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); display: flex; flex-direction: column; gap: 8px; } +.cab-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.cab-label { font-size: 12px; color: #94a3b8; } +.cab-tag { font-size: 11px; line-height: 18px; padding: 0 6px; border-radius: 4px; white-space: nowrap; background: #f1f5f9; color: #475569; } +.cab-tag.danger { background: #fef2f2; color: #dc2626; } +.cab-tag.warn { background: #fffbeb; color: #b45309; } +.cab-tag.full { background: #f0fdf4; color: #16a34a; } +.cab-tag.on { background: #fff7ed; color: #c2410c; } +.cab-tag.none, .cab-tag.miss { background: #f1f5f9; color: #94a3b8; } +.cab-hint { font-size: 10px; color: #94a3b8; } +.cab-raw { font-size: 10px; color: #cbd5e1; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.door-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 30px; padding: 0 14px; margin: 0; font-size: 12px; line-height: 30px; color: #0d9488; background: #f0fdfa; border: 1px solid #99f6e4; border-radius: 6px; } +.door-btn::after { border: none; } +.door-btn:active { background: #ccfbf1; } .info-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; font-size: 13px; } .info-row .label { color: #94a3b8; } .info-row .value { color: #111827; font-variant-numeric: tabular-nums; } @@ -190,18 +311,18 @@ function openLocation() { .chan-tag.pow.forbid { background: #fffbeb; color: #a16207; } .chan-tag.pow.danger { background: #fef2f2; color: #dc2626; } .chan-tag.miss { background: #f1f5f9; color: #cbd5e1; } -.comp.idle { background: #f0fdf4; border-color: #bbf7d0; color: #166534; } -.comp.charging { background: #f0fdfa; border-color: #0d9488; color: #0f766e; animation: border-pulse 1.5s ease-in-out infinite; } -.comp.full { background: #fffbeb; border-color: #f59e0b; color: #92400e; } +/* 通道状态配色(口径:空闲=灰 / 充电中=蓝 / 充满=绿 / 故障=红,与 PC `STATUS_MAP` 一致) */ +.comp.idle { background: #f1f5f9; border-color: #e2e8f0; color: #475569; } +.comp.charging { background: #eff6ff; border-color: #2563eb; color: #1d4ed8; animation: border-pulse 1.5s ease-in-out infinite; } +.comp.full { background: #f0fdf4; border-color: #16a34a; color: #15803d; } .comp.fault { background: #fef2f2; border-color: #ef4444; color: #991b1b; } -.comp.disabled { background: #f1f5f9; border-color: #e2e8f0; color: #94a3b8; } -@keyframes border-pulse { 0%, 100% { border-color: #0d9488; } 50% { border-color: #14b8a6; box-shadow: 0 0 0 2px rgba(13,148,136,0.15); } } +@keyframes border-pulse { 0%, 100% { border-color: #2563eb; } 50% { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); } } .legend { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 12px 16px; flex-wrap: wrap; } .legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #94a3b8; } .legend-dot { width: 8px; height: 8px; border-radius: 2px; } -.legend-dot.idle { background: #f0fdf4; border: 1px solid #bbf7d0; } -.legend-dot.charging { background: #f0fdfa; border: 1px solid #99f6e4; } -.legend-dot.full { background: #fffbeb; border: 1px solid #fde68a; } +.legend-dot.idle { background: #f1f5f9; border: 1px solid #e2e8f0; } +.legend-dot.charging { background: #eff6ff; border: 1px solid #bfdbfe; } +.legend-dot.full { background: #f0fdf4; border: 1px solid #bbf7d0; } .legend-dot.fault { background: #fef2f2; border: 1px solid #fca5a5; } .legend-dot.offline { background: #f1f5f9; border: 1px solid #e2e8f0; } .empty-state { text-align: center; padding: 48px 20px; color: #94a3b8; } diff --git a/software/h5-uniapp/src/pages/home/index.vue b/software/h5-uniapp/src/pages/home/index.vue index 1b66896..67c98b6 100644 --- a/software/h5-uniapp/src/pages/home/index.vue +++ b/software/h5-uniapp/src/pages/home/index.vue @@ -1,10 +1,14 @@ - + 切换组织✕ - - 暂无可选组织(账号可能缺少「查看组织」权限),当前按「全部」展示 + + 暂无可选组织(组织列表加载失败,或系统内还没有客户组织),当前按「全部」展示 全部 {{ org.name }} @@ -86,7 +93,7 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app' import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api' import { useAuthStore } from '../../store/auth' import AppIcon from '../../components/AppIcon.vue' -import { formatCelsius, powerFlagOf } from '../../utils/deviceStatus' +import { formatCelsius, powerFlagOf, cabinetDoorState } from '../../utils/deviceStatus' const statusBarHeight = ref(47) const navBarHeight = ref(44) @@ -122,28 +129,54 @@ const selectedOrgId = ref(null) const currentOrgName = ref('') /** - * 加载组织列表并落到「全部」 + * 是否总管理员(工厂侧,`role_level >= 2`) * - * ⚠️ `GET /api/organizations` 需要 `org:view` 权限,操作员(normal_user)没有该权限 → 403。 - * 前端兜底:**无论成功失败都切到「全部」**,失败时清空列表并给出可见提示 - * (弹层内有提示行 + toast),绝不静默留一个空弹层。 - * 若需让操作员看到自己组织下的设备,需后端提供免 `org:view` 的可见组织接口(前端无法单独解决)。 + * **组织可见性规则(与后端 `/api/my-organizations` 同源):** + * 工厂(总管理员)可见全部组织 → 需要组织选择器; + * 组织管理员(1)/ 操作员(0)只能看到自己所属组织 → **不显示选择器**,固定在自己组织内。 + */ +const isSuperAdmin = computed(() => Number(authStore.user?.role_level ?? 0) >= 2) + +/** + * 加载「可见组织」并确定当前组织 + * + * ⚠️ 数据源是 `GET /api/my-organizations`(仅需登录 + 服务端按角色隔离), + * **不再用**管理端的 `GET /api/organizations`(硬校验 `org:view`,操作员 403 → 弹层永远为空)。 + * 兜底:请求失败也不静默 —— 总管理员落「全部」,客户侧落**自己所属组织**(取不到组织时给可见提示)。 */ async function initOrg() { + let list: OrgItem[] = [] try { - const res = await h5Api.getOrganizations() - orgs.value = res || [] - if (orgs.value.length === 0) { - uni.showToast({ title: '暂无可见组织,已按「全部」展示', icon: 'none' }) - } + list = (await h5Api.getMyOrganizations()) || [] } catch { - orgs.value = [] - uni.showToast({ title: '组织列表加载失败,已按「全部」展示', icon: 'none' }) + list = [] } - // 统一落到「全部」:只改筛选状态,数据由 onShow 的 fetch 拉取,避免重复请求 - applyOrgAll() + orgs.value = list + + if (isSuperAdmin.value) { + // 工厂:默认「全部」(跨组织视角),可自由切换 + applyOrgAll() + if (list.length === 0) { + uni.showToast({ title: '组织列表加载失败,已按「全部」展示', icon: 'none' }) + } + return + } + + // 客户侧:组织来源 = 登录用户信息里的 organization_id,缺失时退回接口返回的第一个组织 + const ownOrgId = authStore.user?.organization_id ?? list[0]?.id ?? null + if (ownOrgId === null) { + // 账号没有组织归属:后端按组织隔离会返回空数据,这里如实提示(不假装「全部」) + selectedOrgId.value = null + currentOrgName.value = '未分配组织' + selectedProjectId.value = null + uni.showToast({ title: '账号未分配组织,暂无可见设备', icon: 'none' }) + return + } + selectedOrgId.value = ownOrgId + currentOrgName.value = list.find((o) => o.id === ownOrgId)?.name || '我的组织' + selectedProjectId.value = null } -/** 切到「全部」(仅状态,不含请求) */ +/** 切到「全部」(仅状态,不含请求;只有总管理员会走到) */ function applyOrgAll() { selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null } function switchOrgAll() { showOrgPicker.value = false; applyOrgAll(); fetchDashboard(); fetchCabinets() } function switchOrg(org: OrgItem) { showOrgPicker.value = false; selectedOrgId.value = org.id; currentOrgName.value = org.name; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() } @@ -156,7 +189,9 @@ onShow(async () => { try { const res = await h5Api.getCabinetByAbstract(scanId); uni.navigateTo({ url: `/pages/device-detail/index?id=${res.id}` }); return } catch { uni.showToast({ title: '设备不存在', icon: 'none' }) } } if (!authStore.isAuthenticated) { uni.navigateTo({ url: '/pages/login/index' }); return } - initOrg(); fetchDashboard(); fetchCabinets() + // 先确定组织再拉数据:非总管理员的 org_id 来自组织初始化结果 + await initOrg() + fetchDashboard(); fetchCabinets() }) onPullDownRefresh(async () => { await Promise.all([fetchDashboard(), fetchCabinets()]); uni.stopPullDownRefresh() }) @@ -198,6 +233,8 @@ function showScanInput() { .home-page { background: #f5f7f8; min-height: 100vh; } .top-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid #f1f5f9; position: sticky; top: 0; z-index: 100; box-sizing: border-box; } .org-switcher { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: #f1f5f9; border-radius: 999px; } +/* 客户侧(组织管理员/操作员):组织名只读展示,**不是**可点选择器 */ +.org-switcher.static { background: none; padding-left: 0; } .org-label { font-size: 14px; font-weight: 500; color: #111827; max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .org-arrow { display: flex; align-items: center; } .scan-btn { display: flex; align-items: center; justify-content: center; background: none; color: #111827; border: none; padding: 0; } @@ -241,6 +278,8 @@ function showScanInput() { .pow-tag.off { background: #fee2e2; color: #dc2626; } /* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */ .pow-tag.unknown { background: #f1f5f9; color: #94a3b8; } +/* 柜级柜门未关(协议 §3.8 bit4 / §4.2):红底提示,状态来自最近一次上报(约 60 秒一次) */ +.pow-tag.door { background: #fee2e2; color: #dc2626; } .device-name { font-size: 14px; font-weight: 500; color: #111827; display: block; margin-bottom: 4px; } .device-imei { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; display: block; margin-bottom: 4px; opacity: 0.7; } .device-addr { font-size: 12px; color: #475569; display: block; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/software/h5-uniapp/src/utils/deviceStatus.ts b/software/h5-uniapp/src/utils/deviceStatus.ts index abcfef2..cb51c83 100644 --- a/software/h5-uniapp/src/utils/deviceStatus.ts +++ b/software/h5-uniapp/src/utils/deviceStatus.ts @@ -1,10 +1,22 @@ /** - * 通道状态位解析(协议 §3.8) + * 状态位解析(协议 §3.8 / §5.4)—— 小程序端 * * 协议规定:通道状态是一个字节对,**「电池状态」与「电源状态」是两个相互独立的维度**: * - `batt_status`(2 位 hex):bit0 充满 / bit1 正在充电 / bit4 过欠压 / bit5 过流 / bit6 热失控 / bit7 有电池 * - `pow_status` (2 位 hex):bit0 电源故障 / bit1 电源打开 / bit2 禁止打开 / bit7 有电源 * + * ⚠️ **状态位按归属层级展示(产品已确认,协议 §3.8)**: + * - **柜级**:`cabinet.status1` / `cabinet.status2` → 展示在**柜子**层面(设备详情页), + * 见 [`parseCabinetStatus1`] / [`parseCabinetStatus2`]; + * - **仓体级**:`batt_status` / `pow_status` → 展示在**仓体**层面(通道详情页), + * 见 [`parseBattStatus`] / [`parsePowStatus`]。 + * **仓体(通道)层面不显示任何"门"状态** —— 协议 §4.2 L403:柜门是整柜共用的一扇, + * 没有"按仓体(舱门)开门"的指令,也没有"关门"指令。 + * + * 🔗 PC 管理端同名解析函数在 `web/src/utils/deviceStatus.ts` + * ([`parseCabinetStatus1`] / [`parseCabinetStatus2`] 两端的 bit 定义与"未知"口径必须一致, + * 改一边务必同步另一边)。 + * * tone 对应模板里 `.chan-tag.batt.` / `.chan-tag.pow.` 的样式类; * 字段缺失 / 格式非法时返回 null,由调用方展示 '-' */ @@ -78,6 +90,137 @@ export function powStatusOf(hex?: string | null): ChannelStatus { return parsePowStatus(hex) ?? { label: '-', tone: 'miss' } } +// --------------------------------------------------------------------------- +// 柜级状态位(协议 §3.8 —— `cabinet.status1` / `cabinet.status2`,**归属层级 = 柜子**) +// +// ⛔ 这两个字段**不是**单标志位域,绝不能套用 `pow_fail_dc` 那套 `bool | null`; +// 也**不要**在仓体(通道)层面展示 —— 仓体层面只有 `batt_status` / `pow_status`。 +// 🔗 bit 定义与 PC 端 `web/src/utils/deviceStatus.ts` 的同名函数**逐位一致**。 +// --------------------------------------------------------------------------- + +/** `cabinet.status1` 各置位含义(协议 §3.8,8 位全部有定义,无保留位) */ +export interface CabinetStatus1 { + /** bit0 `0x01` 断电 */ + powerFail: boolean + /** bit1 `0x02` 烟雾 */ + smoke: boolean + /** bit2 `0x04` 水位 */ + waterLevel: boolean + /** bit3 `0x08` 倾倒 */ + tilt: boolean + /** bit4 `0x10` **柜门打开**(`=0` 柜门关闭);整柜共用一扇门 */ + doorOpen: boolean + /** bit5 `0x20` 加热风扇打开 */ + heaterFanOn: boolean + /** bit6 `0x40` 接触器断开(`=0` 导通) */ + contactorOpen: boolean + /** bit7 `0x80` 喷淋打开 */ + sprinklerOn: boolean +} + +/** `cabinet.status2` 置位含义(协议 §3.8;bit2–bit7 硬件保留,平台忽略、不展示) */ +export interface CabinetStatus2 { + /** bit0 `0x01` 声光报警 */ + soundLightAlarm: boolean + /** + * bit1 `0x02` **柜门打开超时** + * ⚠️ 阈值由**硬件侧**定义(硬件文档未给秒数),平台**只呈现、不自行判定** + */ + doorOpenTimeout: boolean +} + +/** + * 解析柜级 `cabinet.status1`(协议 §3.8) + * @param hex 后端 `status1`(2 位 hex 原样透出,如 `"10"`) + * @returns 具名布尔对象;**缺失 / 非法(长度不对、非 hex)→ `null`("未知")** + */ +export function parseCabinetStatus1(hex?: string | null): CabinetStatus1 | null { + const value = parseHexByte(hex) + if (value === null) return null + return { + powerFail: (value & 0x01) !== 0, + smoke: (value & 0x02) !== 0, + waterLevel: (value & 0x04) !== 0, + tilt: (value & 0x08) !== 0, + doorOpen: (value & 0x10) !== 0, + heaterFanOn: (value & 0x20) !== 0, + contactorOpen: (value & 0x40) !== 0, + sprinklerOn: (value & 0x80) !== 0, + } +} + +/** + * 解析柜级 `cabinet.status2`(协议 §3.8;保留位 bit2–bit7 一律不展示) + * @returns 具名布尔对象;**缺失 / 非法 → `null`("未知")** + */ +export function parseCabinetStatus2(hex?: string | null): CabinetStatus2 | null { + const value = parseHexByte(hex) + if (value === null) return null + return { + soundLightAlarm: (value & 0x01) !== 0, + doorOpenTimeout: (value & 0x02) !== 0, + } +} + +/** + * 柜门三态(协议 §4.2) + * - `'open'`:`status1` bit4 = 1(**门已被人拉开**) + * - `'closed'`:`status1` bit4 = 0 + * - `'unknown'`:无数据 / 非法 → 灰色「未知」(**不可显示成"关闭"**) + * + * ⚠️ 柜门状态**永远来自 `status_post`(约 60 秒一次)**,不是实时量; + * 平台下发的 `open` 只**解锁**,门要人拉开(关门也由人操作,平台无"关门"指令)。 + */ +export type DoorState = 'open' | 'closed' | 'unknown' + +/** 柜门三态判定(依据见 [`DoorState`]) */ +export function cabinetDoorState(status1?: string | null): DoorState { + const bits = parseCabinetStatus1(status1) + if (bits === null) return 'unknown' + return bits.doorOpen ? 'open' : 'closed' +} + +/** 柜门展示标签(文案 + 样式类后缀),`unknown` 用灰色且文案明确是"未知" */ +export function cabinetDoorTag(status1?: string | null): ChannelStatus { + const state = cabinetDoorState(status1) + if (state === 'open') return { label: '柜门打开', tone: 'danger' } + if (state === 'closed') return { label: '柜门关闭', tone: 'full' } + return { label: '柜门未知', tone: 'miss' } +} + +/** + * 柜级告警标签(协议 §3.8)—— 把 `status1` + `status2` 的**置位项**展开为标签列表。 + * + * 规则(与 PC 端 `cabinetStatusTags` 完全一致): + * - **柜门(bit4)不在其中**:它是状态而非告警,单独用 [`cabinetDoorTag`] 展示; + * - 任一字段**无数据(null)→ 整体返回 `null`**(调用方展示「未知(未上报)」), + * **不能返回空数组** —— 空数组会被误读成"全部正常"; + * - 有数据且没有任何位置位(`"00"`)时返回 `[]` = 确实全部正常; + * - 位定义顺序即展示顺序(报警类在前,加热风扇这类运行状态放最后)。 + */ +export function cabinetStatusTags( + status1?: string | null, + status2?: string | null, +): ChannelStatus[] | null { + const s1 = parseCabinetStatus1(status1) + const s2 = parseCabinetStatus2(status2) + // 任一维度没数据 → 整体"未知"(不猜测另一半是否正常) + if (s1 === null || s2 === null) return null + + const tags: ChannelStatus[] = [] + if (s1.powerFail) tags.push({ label: '断电', tone: 'danger' }) + if (s1.smoke) tags.push({ label: '烟雾', tone: 'danger' }) + if (s1.waterLevel) tags.push({ label: '水位', tone: 'warn' }) + if (s1.tilt) tags.push({ label: '倾倒', tone: 'danger' }) + if (s1.sprinklerOn) tags.push({ label: '喷淋', tone: 'danger' }) + if (s1.contactorOpen) tags.push({ label: '接触器断开', tone: 'warn' }) + if (s2.soundLightAlarm) tags.push({ label: '声光报警', tone: 'danger' }) + if (s2.doorOpenTimeout) tags.push({ label: '柜门打开超时', tone: 'warn' }) + // 加热风扇是运行状态而非告警,放最后(可选项) + if (s1.heaterFanOn) tags.push({ label: '加热风扇', tone: 'on' }) + return tags +} + /** * 是否处于停电状态(协议 §5.4) * 后端契约为 `boolean | null`(true=停电 / false=正常 / null=无数据) diff --git a/software/web/src/api/devices.ts b/software/web/src/api/devices.ts index bc58944..2b514da 100644 --- a/software/web/src/api/devices.ts +++ b/software/web/src/api/devices.ts @@ -65,6 +65,21 @@ export interface Cabinet { * 未返回 / 设备未上报时为 `undefined` / `null`,界面展示 `--°C`。 */ temp?: number | null; + /** + * **柜级**柜体状态1(协议 §3.8,2 位 hex 原样透出,如 `"10"` = 柜门打开、`"82"` = 喷淋 + 烟雾) + * + * 位域含义由前端纯函数解析(`@/utils/deviceStatus` 的 `parseCabinetStatus1`)。 + * ⚠️ `null` = **无数据**,`"00"` = **全部正常** —— 两者绝不能混为一谈。 + * ⚠️ 展示层级 = **柜子**(不是仓体)。 + */ + status1?: string | null; + /** + * **柜级**柜体状态2(协议 §3.8,2 位 hex 原样透出) + * + * bit0 声光报警 / bit1 柜门打开超时(阈值硬件定义,平台只呈现);bit2–7 保留位忽略。 + * `null` = 无数据。 + */ + status2?: string | null; } /** diff --git a/software/web/src/api/permission.ts b/software/web/src/api/permission.ts index 880dd6d..d094c11 100644 --- a/software/web/src/api/permission.ts +++ b/software/web/src/api/permission.ts @@ -24,14 +24,32 @@ export interface PermissionItem { /** 用户权限范围条目 */ export interface ScopeEntry { id?: number; - /** 1组织 2项目 3设备 */ + /** 1组织 2项目 3设备(**3 设备级不参与鉴权**,前端不再提供该维度) */ scope_type: number; /** 对应的组织/项目/设备ID */ scope_id: number; /** 1查看 2操作 */ permission_type: number; + /** + * 该条记录是否会被鉴权消费(后端读取时给出) + * + * `false` = 脏数据(资源已删除 / 跨组织 / 设备级),鉴权时被忽略。 + * 仅 `GET /users/{id}/scopes` 返回;写入时不传。 + */ + valid?: boolean; } +/** 数据范围类型:组织 */ +export const SCOPE_TYPE_ORG = 1; +/** 数据范围类型:项目(**实际生效粒度**) */ +export const SCOPE_TYPE_PROJECT = 2; +/** 数据范围类型:设备/柜子(不参与鉴权,前端不再使用) */ +export const SCOPE_TYPE_CABINET = 3; +/** 数据范围权限类型:查看 */ +export const SCOPE_PERMISSION_VIEW = 1; +/** 数据范围权限类型:操作 */ +export const SCOPE_PERMISSION_OPERATE = 2; + /** 用户权限详情 */ export interface UserPermissionDetail { user_id: number; diff --git a/software/web/src/components/UserDataScopeModal.tsx b/software/web/src/components/UserDataScopeModal.tsx new file mode 100644 index 0000000..1703273 --- /dev/null +++ b/software/web/src/components/UserDataScopeModal.tsx @@ -0,0 +1,300 @@ +/** + * 用户「数据范围」配置弹窗 + * + * 语义(与后端 `middleware/auth.rs::load_user_scopes` 一一对应,详见 AGENTS.md): + * - **未配置任何范围 = 不限制**(该用户可见其所属组织下的全部数据,与历史行为完全一致); + * - 配置后 = 在组织隔离之上**再收窄**到所选 组织/项目(取交集,绝不会放大可见范围); + * - **总管理员(role_level >= 2)豁免**:始终可见全量数据,配置它也不会生效; + * - 范围**粒度到项目为止**:UI 只提供「组织」「项目」两个维度,不提供设备/柜子级 + * (设备级 scope 后端不消费,历史脏数据会被忽略)。 + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Empty, Message, Modal, Select, Space, Spin, Tag, Typography } from '@arco-design/web-react'; +import { + fetchUserScopes, + setUserScopes, +} from '@/api/permission'; +import type { ScopeEntry } from '@/api/permission'; +import { + SCOPE_PERMISSION_VIEW, + SCOPE_TYPE_ORG, + SCOPE_TYPE_PROJECT, +} from '@/api/permission'; +import { projectApi } from '@/api/devices'; +import type { Project } from '@/api/devices'; + +/** 组件 Props */ +export interface UserDataScopeModalProps { + /** 是否可见 */ + visible: boolean; + /** 目标用户 ID */ + userId: number | null; + /** 目标用户展示名(姓名或手机号) */ + userName: string; + /** 目标用户的角色等级(0操作员 1企业管理员 2总管理员) */ + roleLevel: number; + /** 目标用户所属组织 ID(总管理员为 null) */ + organizationId: number | null; + /** 目标用户所属组织名称(用于展示) */ + organizationName?: string | null; + /** 关闭弹窗 */ + onClose: () => void; +} + +/** 组织/项目多选的选项类型 */ +interface Option { + label: string; + value: number; +} + +/** + * 从存量范围记录中拆出「组织级 / 项目级」两个维度的 id 列表 + * + * 设备级(scope_type=3)与未知类型一律忽略 —— 与后端鉴权口径保持一致。 + */ +function splitScopeEntries(scopes: ScopeEntry[]): { orgIds: number[]; projectIds: number[] } { + const orgIds: number[] = []; + const projectIds: number[] = []; + scopes.forEach((s) => { + if (s.scope_type === SCOPE_TYPE_ORG && !orgIds.includes(s.scope_id)) { + orgIds.push(s.scope_id); + } else if (s.scope_type === SCOPE_TYPE_PROJECT && !projectIds.includes(s.scope_id)) { + projectIds.push(s.scope_id); + } + }); + return { orgIds, projectIds }; +} + +export default function UserDataScopeModal({ + visible, + userId, + userName, + roleLevel, + organizationId, + organizationName, + onClose, +}: UserDataScopeModalProps) { + /** 已选组织(数据范围的组织级) */ + const [selectedOrgIds, setSelectedOrgIds] = useState([]); + /** 已选项目(数据范围的项目级) */ + const [selectedProjectIds, setSelectedProjectIds] = useState([]); + /** 该用户所属组织下的项目(可选项) */ + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + /** 存量的无效记录条数(脏数据提示) */ + const [invalidCount, setInvalidCount] = useState(0); + + const isSuperAdmin = roleLevel >= 2; + + /** 组织维度只有「该用户所属组织」一种合法取值(跨组织配置后端会 400) */ + const orgOptions: Option[] = useMemo( + () => + organizationId === null + ? [] + : [{ label: organizationName || `组织 #${organizationId}`, value: organizationId }], + [organizationId, organizationName], + ); + + const projectOptions: Option[] = useMemo( + () => projects.map((p) => ({ label: p.name, value: p.id })), + [projects], + ); + + /** 加载存量范围 + 可选项目 */ + const loadData = useCallback(async () => { + if (userId === null) return; + setLoading(true); + try { + const scopes = await fetchUserScopes(userId); + const { orgIds, projectIds } = splitScopeEntries(scopes); + setSelectedOrgIds(orgIds); + setSelectedProjectIds(projectIds); + setInvalidCount(scopes.filter((s) => s.valid === false).length); + } catch (e) { + Message.error((e as Error)?.message || '加载数据范围失败'); + setSelectedOrgIds([]); + setSelectedProjectIds([]); + setInvalidCount(0); + } + + if (organizationId !== null) { + try { + const res = await projectApi.list(organizationId); + setProjects(res.data || []); + } catch { + // 项目列表拉不到不影响弹窗可用(已选项目仍会原样回传) + setProjects([]); + } + } else { + setProjects([]); + } + setLoading(false); + }, [userId, organizationId]); + + useEffect(() => { + if (visible) { + loadData(); + } + }, [visible, loadData]); + + /** 保存(全量替换):组织级 + 项目级都按「查看」权限提交 */ + const handleSave = async () => { + if (userId === null) return; + const entries: ScopeEntry[] = [ + ...selectedOrgIds.map((id) => ({ + scope_type: SCOPE_TYPE_ORG, + scope_id: id, + permission_type: SCOPE_PERMISSION_VIEW, + })), + ...selectedProjectIds.map((id) => ({ + scope_type: SCOPE_TYPE_PROJECT, + scope_id: id, + permission_type: SCOPE_PERMISSION_VIEW, + })), + ]; + + setSaving(true); + try { + await setUserScopes(userId, entries); + Message.success(entries.length === 0 ? '已清空数据范围(= 不限制)' : '数据范围已保存'); + onClose(); + } catch (e) { + Message.error((e as Error)?.message || '保存失败'); + } finally { + setSaving(false); + } + }; + + /** 清空选择 = 恢复「不限制」 */ + const handleClear = () => { + setSelectedOrgIds([]); + setSelectedProjectIds([]); + }; + + const noneSelected = selectedOrgIds.length === 0 && selectedProjectIds.length === 0; + + return ( + + + + {noneSelected ? ( + + ) : ( + + )} + + {isSuperAdmin && ( + + )} + + {organizationId === null && !isSuperAdmin && ( + + )} + + {invalidCount > 0 && ( + + )} + +
+ 组织级范围 + + 选中 = 该组织下全部项目可见(只能选该用户所属组织) + + setSelectedProjectIds(v as number[])} + options={projectOptions} + disabled={isSuperAdmin || organizationId === null} + style={{ width: '100%', marginTop: 6 }} + /> +
+ + {projectOptions.length === 0 && organizationId !== null && !loading && ( + + )} + + + + 当前选择: + + {noneSelected && 不限制} + {selectedOrgIds.map((id) => ( + + 组织:{orgOptions.find((o) => o.value === id)?.label ?? `#${id}`} + + ))} + {selectedProjectIds.map((id) => ( + + 项目:{projectOptions.find((o) => o.value === id)?.label ?? `#${id}`} + + ))} + + +
+ + 说明:数据范围粒度到**项目级**(设备/柜子级不在本机制范围内); + 历史脏数据(指向已删除或跨组织的资源)会被鉴权忽略, + 若因此一条有效范围都不剩,则按「未配置 = 不限制」处理。 + +
+ + {!noneSelected && !isSuperAdmin && organizationId !== null && ( + 清空选择(恢复为不限制) + )} + + + + ); +} diff --git a/software/web/src/pages/devices/CabinetGrid.tsx b/software/web/src/pages/devices/CabinetGrid.tsx index 3546969..5da03fc 100644 --- a/software/web/src/pages/devices/CabinetGrid.tsx +++ b/software/web/src/pages/devices/CabinetGrid.tsx @@ -30,7 +30,7 @@ import QRCode from 'qrcode'; import { cabinetApi } from '@/api/devices'; import type { Cabinet } from '@/api/devices'; import { useAuthStore } from '@/stores/auth'; -import { formatTempK100, normalizeFlag } from '@/utils/deviceStatus'; +import { cabinetDoorState, formatTempK100, normalizeFlag } from '@/utils/deviceStatus'; interface CabinetGridProps { cabinets: Cabinet[]; @@ -414,6 +414,12 @@ export default function CabinetGrid({ cabinets, loading, onRefresh }: CabinetGri {cab.status === 2 && ( 禁用 )} + {/* 柜级柜门(协议 §3.8 bit4 / §4.2):**只在"打开"时**提示。 + 状态来自 status_post(约 60 秒一次),非实时; + `status1` 缺失(无数据)时不提示 —— 不猜测、不显示成"已关"。 */} + {cabinetDoorState(cab.status1) === 'open' && ( + 柜门未关 + )} diff --git a/software/web/src/pages/devices/cabinet.tsx b/software/web/src/pages/devices/cabinet.tsx index d9b0a0f..cd4b359 100644 --- a/software/web/src/pages/devices/cabinet.tsx +++ b/software/web/src/pages/devices/cabinet.tsx @@ -1,7 +1,13 @@ /** * 柜子详情页 * - 显示柜子基本信息(ID、状态、充电模式) + * - **柜级**状态位(协议 §3.8:`status1`/`status2`)+ 柜门(整柜共用一扇,数据来自 `status_post`) * - 按仓控板分组展示 6 个通道的状态卡片(电池状态 / 电源状态两个独立维度) + * + * ⚠️ 展示层级(产品已确认,协议 §3.8): + * - **柜级**位域(断电/烟雾/水位/倾倒/柜门/加热风扇/接触器/喷淋/声光报警/柜门超时)只在"柜子"层面出现; + * - **仓体级**只有 `batt_status` / `pow_status`,仓体卡片里**不出现任何"门"状态** + * (协议 §4.2 L403:柜门整柜共用一扇,**没有"按仓体开门"的指令**)。 */ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; @@ -22,14 +28,30 @@ import { import { IconLeft, IconDesktop, IconCopy, IconUnlock, IconPlayArrow, IconPoweroff, IconSwap, IconRefresh } from '@arco-design/web-react/icon'; import { cabinetApi, orgApi, projectApi, type CabinetDetail, type Organization, type Project } from '@/api/devices'; import { useAuthStore } from '@/stores/auth'; -import { formatTempK100, normalizeFlag, parseBattStatus, parsePowStatus } from '@/utils/deviceStatus'; +import { Permission } from '@/components/Permission'; +import { + cabinetDoorTag, + cabinetStatusTags, + formatTempK100, + normalizeFlag, + parseBattStatus, + parsePowStatus, +} from '@/utils/deviceStatus'; -/** 状态映射 */ +/** + * 通道(仓体)状态映射 + * + * 口径:`0空闲 / 1充电中 / 2充满 / 3故障`,与后端派生规则 + * (`api-server/src/routes/cabinet_tree.rs::derive_channel_flags`,优先级 fault > full > charging > idle)、 + * 小程序 `device-detail/index.vue::compClass`、`compartment-detail/index.vue::statusText` **三处完全一致**。 + * ⚠️ 仓体级**没有"禁用"档**(禁用是设备/组织级状态,走 `CABINET_STATUS_MAP`)。 + * 颜色取常识口径:空闲=灰 / 充电中=蓝 / 充满=绿 / 故障=红。 + */ const STATUS_MAP: Record = { - 0: { label: '空闲', color: 'green' }, + 0: { label: '空闲', color: 'gray' }, 1: { label: '充电中', color: 'blue' }, - 2: { label: '故障', color: 'red' }, - 3: { label: '禁用', color: 'orange' }, + 2: { label: '充满', color: 'green' }, + 3: { label: '故障', color: 'red' }, }; /** 柜子状态映射 */ @@ -220,15 +242,18 @@ export default function CabinetDetail() { const handleOpenDoor = () => { if (!id) return; openConfirm( - '解锁柜门', - '确定要解锁柜门吗?柜门是整柜共用的一扇,解锁后需要人手动拉开;平台无"关门"指令,关门也由人操作。', + '解锁柜门(下发的是解锁,门要人拉开)', + '柜门是整柜共用的一扇:平台下发的是「解锁」,解锁后需要人到现场把门拉开,门不会自动打开,' + + '柜门打开状态最迟 60 秒内随设备上报更新(非实时)。平台没有「关门」指令,关门也由人操作。\n' + + '⚠️ 开门与充电/订单没有任何联动,不会影响正在进行的充电。', async () => { setDoorLoading(true); try { + // 柜级指令:不带 id(协议 §4.2);不触碰订单与充电 await cabinetApi.openCabinet(Number(id)); - Message.success('解锁指令已发送'); - } catch { - Message.error('开门失败'); + Message.success('解锁指令已发送,请到现场拉开柜门'); + } catch (e: unknown) { + Message.error(e instanceof Error ? e.message : '开门失败'); } finally { setDoorLoading(false); } @@ -461,6 +486,10 @@ export default function CabinetDetail() { const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '未知', color: 'gray' }; /** 充电工作模式:后端未返回时为 null(界面上按默认「平台指令模式」展示并标注) */ const workMode: '0' | '1' | null = detail.work_mode === '1' ? '1' : detail.work_mode === '0' ? '0' : null; + /** 柜级柜门三态(协议 §4.2):`status1` bit4;无数据 → 「未知」(绝不显示成"关闭") */ + const doorTag = cabinetDoorTag(detail.status1); + /** 柜级告警标签(协议 §3.8):`null` = 该维度无数据(与"确实无告警"的 `[]` 区分) */ + const cabinetTags = cabinetStatusTags(detail.status1, detail.status2); return (
@@ -501,6 +530,32 @@ export default function CabinetDetail() {
+ {/* 柜级状态位(协议 §3.8):**归属层级 = 柜子**,仓体卡片里不出现这些位 */} +
+ {/* 柜门(协议 §4.2):整柜共用一扇;平台下发的 open 只是"解锁",门要人拉开, + 关门也由人操作;状态永远来自 status_post(约 60 秒一次),**不是实时量** */} + + 🚪 {doorTag.label} + + (来自最近一次上报,约 60 秒一次,非实时) + 柜级告警 + {cabinetTags === null ? ( + // 无数据:绝不能显示成"无告警"(那会把"没上报"说成"正常") + + 未知(未上报) + + ) : cabinetTags.length === 0 ? ( + + 无 + + ) : ( + cabinetTags.map((t) => {t.label}) + )} + + 原始值 status1={detail.status1 ?? 'null'} / status2={detail.status2 ?? 'null'} + +
+ {/* 第三行:基本信息 + 操作按钮 */}
@@ -509,7 +564,12 @@ export default function CabinetDetail() { 名称: {detail.name || '-'}
- + {/* 开门(柜级解锁):权限码 charge:open_door(与后端 check_permission 一致)。 + ⚠️ 这是**独立流程**,与订单/充电互不联动(协议 §5.7 / §7.2) */} + + + + {/* 重置安全码:仅总管理员可见(后端权限码 device:auth_str:reset) */} diff --git a/software/web/src/pages/settings/users.tsx b/software/web/src/pages/settings/users.tsx index fcd5867..3b22219 100644 --- a/software/web/src/pages/settings/users.tsx +++ b/software/web/src/pages/settings/users.tsx @@ -34,6 +34,7 @@ import type { User } from '@/api/user'; import { orgApi } from '@/api/devices'; import type { Organization } from '@/api/devices'; import { Permission } from '@/components/Permission'; +import UserDataScopeModal from '@/components/UserDataScopeModal'; import { useAuthStore } from '@/stores/auth'; const ROLE_OPTIONS = [ @@ -90,6 +91,10 @@ export default function UsersPage() { const [orgSaving, setOrgSaving] = useState(false); const [orgKeyword, setOrgKeyword] = useState(''); + // 数据范围弹窗 + const [scopeModalVisible, setScopeModalVisible] = useState(false); + const [scopeUser, setScopeUser] = useState(null); + const filteredOrgs = orgs.filter(o => !orgKeyword.trim() || o.name.toLowerCase().includes(orgKeyword.trim().toLowerCase()) ); @@ -319,6 +324,12 @@ export default function UsersPage() { } }; + /** 打开数据范围弹窗 */ + const handleOpenScopes = (user: User) => { + setScopeUser(user); + setScopeModalVisible(true); + }; + /** 表格列 */ const columns: TableColumnProps[] = [ { title: '手机号', dataIndex: 'phone', width: 130 }, @@ -358,12 +369,18 @@ export default function UsersPage() { }, { title: '操作', - width: 100, + width: 140, render: (_: unknown, record: User) => ( +