From 19d5b65625f3cb4cf3996fb995c3e9193e2273d5 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 28 Jul 2026 23:16:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(db):=20=E6=96=B0=E5=A2=9E=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=88=97=E7=B1=BB=E5=9E=8B=E9=80=82=E9=85=8D=E5=B1=82=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95=E7=BB=8F=20db=20=E5=A4=84?= =?UTF-8?q?=E7=90=86=20DATETIME=20=E5=88=97=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON 无日期类型,而 SQLite DATETIME 只接受 Python datetime 对象, 导致各服务对日期列的写入与 where 比较全部失败。_coerce_value 按反射 列类型把 ISO 8601 字符串转为 datetime/date/time(带时区偏移统一转 UTC),并支持 "$now" 哨兵取服务端当前时间;响应中日期时间改为一律 序列化为 ISO 8601(T 分隔)。黑盒测试补 3 个日期列场景。 --- README.md | 1 + db/README.md | 36 +++++++++++++- db/dbv2.py | 98 +++++++++++++++++++++++++++++++++++---- tests/blackbox/test_db.py | 43 +++++++++++++++++ 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c5faffd..19d3ef6 100755 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ - 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。 - 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。 - 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。 +- **日期/时间列适配**:values 与 where 中的 DATETIME/DATE/TIME 列接受 ISO 8601 字符串(带时区偏移自动转 UTC)或哨兵值 `"$now"`(服务端当前 UTC);响应中的日期时间一律序列化为 ISO 8601(T 分隔)。 - **内置日志子模块**: - 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。 - 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。 diff --git a/db/README.md b/db/README.md index 303817a..3f52b8e 100644 --- a/db/README.md +++ b/db/README.md @@ -78,7 +78,7 @@ { "success": true, "data": [ - {"id": 1, "username": "Alice"} + {"id": 1, "username": "Alice", "create_at": "2026-07-28T14:30:00"} ] } ``` @@ -91,6 +91,40 @@ {"success": false, "error": "错误描述"} ``` +## 日期/时间列的处理 + +JSON 没有日期类型,db 内置了类型适配层:按反射得到的列类型自动在 +ISO 8601 字符串与数据库 DATETIME / DATE / TIME 之间转换。 + +**写入(values / where 条件值)**: + +| 输入 | 说明 | +|------|------| +| `"2026-07-28T22:30:00"` | ISO 8601(T 分隔),推荐 | +| `"2026-07-28 22:30:00"` | 空格分隔同样接受(SQLite 原生格式) | +| `"2026-07-28T22:30:00+08:00"` | 带时区偏移:自动转 UTC 后存储 | +| `"2026-07-28T14:30:00Z"` | Z 后缀等同 +00:00 | +| `"$now"` | 哨兵值:服务端当前 UTC 时间(DATETIME/DATE 列可用) | + +存储统一为 UTC 朴素时间(无时区)。非法日期字符串会返回明确的错误: +`{"success": false, "error": "列 last_login 的日期时间值无法解析(需 ISO 8601 或 \"$now\"): ..."}` + +**读取(select 响应)**:日期时间值一律序列化为 ISO 8601(T 分隔), +如 `"2026-07-28T22:30:00"`,客户端可用 `datetime.fromisoformat()` 直接解析。 + +**注意**:`like` 运算符是字符串模式匹配,对日期列使用时不做转换。 + +```json +// 示例:登录后更新 last_login 为服务端当前时间 +{ + "_route": "execute", + "action": "update", + "table": "Users", + "values": {"last_login": "$now"}, + "where": {"username": "alice"} +} +``` + ## 使用示例(Python + pymsg) ```python diff --git a/db/dbv2.py b/db/dbv2.py index 00cd52b..e940015 100644 --- a/db/dbv2.py +++ b/db/dbv2.py @@ -18,18 +18,26 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。 支持的路由:: execute → 通用 CRUD(select / insert / update / delete) + +日期/时间列:: + + values 与 where 中的 DATETIME / DATE / TIME 列接受 ISO 8601 字符串 + (如 "2026-07-28T22:30:00",可带时区偏移,统一转 UTC 存储), + 或哨兵值 "$now"(服务端当前 UTC 时间)。 + 响应中的日期时间一律序列化为 ISO 8601(T 分隔)。 """ import json import os import time import traceback -from datetime import datetime, timezone, timedelta +from datetime import datetime, date, time as dt_time, timezone, timedelta from sqlalchemy import ( create_engine, MetaData, Table, select, insert, update, delete, func, and_, ) +from sqlalchemy import types as sa_types from sqlalchemy.exc import SQLAlchemyError # pymsg 导入 @@ -50,6 +58,16 @@ DEFAULT_RETENTION_DAYS = 30 DEFAULT_MAX_RECORDS = 1_000_000 DEFAULT_CLEANUP_INTERVAL = 3600 # 秒 +#: where/values 中的哨兵值:服务端当前 UTC 时间 +NOW_SENTINEL = "$now" + + +def _json_default(o): + """json.dumps 兜底序列化:日期时间一律 ISO 8601(T 分隔),其余 str()。""" + if isinstance(o, (datetime, date, dt_time)): + return o.isoformat() + return str(o) + # ═══════════════════════════════════════════════════════════════════════ # DBServer — 主服务类 @@ -113,7 +131,8 @@ class DBServer: resp["data"] = data if error is not None: resp["error"] = error - self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str)) + self._server.reply(sock, json.dumps(resp, ensure_ascii=False, + default=_json_default)) # ── 表反射 --------------------------------------------------------- def _get_table(self, table_name): @@ -121,6 +140,54 @@ class DBServer: Table(table_name, self.metadata, autoload_with=self.engine) return self.metadata.tables[table_name] + # ── 日期/时间类型适配 ------------------------------------------------ + def _coerce_value(self, column, value): + """按反射列类型把 JSON 值适配为 SQLAlchemy 需要的 Python 对象。 + + JSON 没有日期类型,客户端只能传字符串;而 SQLite 的 DATETIME/DATE + 类型只接受 Python datetime/date 对象,字符串会被直接拒绝 + (TypeError: SQLite DateTime type only accepts Python datetime ...)。 + 这里统一把 ISO 8601 字符串转为对应对象;带时区偏移的一律转 UTC + 并去掉 tzinfo,与 SQLite 朴素存储保持一致。 + """ + if not isinstance(value, str): + return value + + col_type = column.type + if isinstance(col_type, sa_types.DateTime): + if value == NOW_SENTINEL: + return datetime.now(timezone.utc).replace(tzinfo=None) + text = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + dt = datetime.fromisoformat(text) + except ValueError: + raise ValueError( + f"列 {column.name} 的日期时间值无法解析" + f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}" + ) + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + if isinstance(col_type, sa_types.Date): + if value == NOW_SENTINEL: + return datetime.now(timezone.utc).date() + try: + return date.fromisoformat(value) + except ValueError: + raise ValueError( + f"列 {column.name} 的日期值无法解析" + f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}" + ) + if isinstance(col_type, sa_types.Time): + try: + return dt_time.fromisoformat(value) + except ValueError: + raise ValueError( + f"列 {column.name} 的时间值无法解析" + f"(需 ISO 8601): {value!r}" + ) + return value + def _build_where(self, table, conditions): if not conditions: return None @@ -129,23 +196,26 @@ class DBServer: column = table.columns[col] if isinstance(val, dict): if "eq" in val: - clauses.append(column == val["eq"]) + clauses.append(column == self._coerce_value(column, val["eq"])) if "ne" in val: - clauses.append(column != val["ne"]) + clauses.append(column != self._coerce_value(column, val["ne"])) if "gt" in val: - clauses.append(column > val["gt"]) + clauses.append(column > self._coerce_value(column, val["gt"])) if "lt" in val: - clauses.append(column < val["lt"]) + clauses.append(column < self._coerce_value(column, val["lt"])) if "ge" in val: - clauses.append(column >= val["ge"]) + clauses.append(column >= self._coerce_value(column, val["ge"])) if "le" in val: - clauses.append(column <= val["le"]) + clauses.append(column <= self._coerce_value(column, val["le"])) if "like" in val: + # like 是字符串模式匹配,不做日期转换 clauses.append(column.like(val["like"])) if "in" in val: - clauses.append(column.in_(val["in"])) + clauses.append(column.in_( + [self._coerce_value(column, v) for v in val["in"]] + )) else: - clauses.append(column == val) + clauses.append(column == self._coerce_value(column, val)) return and_(*clauses) # ═══════════════════════════════════════════════════════════════════ @@ -205,6 +275,10 @@ class DBServer: def _do_insert(self, conn, table, data): values = data.get("values", {}) + values = { + k: self._coerce_value(table.columns[k], v) + for k, v in values.items() + } stmt = insert(table).values(**values) res = conn.execute(stmt) conn.commit() @@ -212,6 +286,10 @@ class DBServer: def _do_update(self, conn, table, data): values = data.get("values", {}) + values = { + k: self._coerce_value(table.columns[k], v) + for k, v in values.items() + } where_cond = self._build_where(table, data.get("where")) stmt = update(table).values(**values) if where_cond is not None: diff --git a/tests/blackbox/test_db.py b/tests/blackbox/test_db.py index 4be4a96..0e8461f 100644 --- a/tests/blackbox/test_db.py +++ b/tests/blackbox/test_db.py @@ -6,6 +6,7 @@ db 黑盒用户模拟测试 — pymsg 协议 CRUD。 2. 通过 pymsg 协议查询 Logs 表 3. 通过 pymsg 协议带 where 条件查询 4. 无效路由被拒绝 + 5. 日期列 where(ISO 字符串 / $now 哨兵 / 非法值拒绝) 注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过 log_client.py 走 execute 路由 INSERT 写入 Logs 表。 @@ -101,6 +102,48 @@ def run(url: str = None): "pymsg-无效路由-拒绝", c, ) + # 场景 5: 日期列 where — ISO 8601 字符串(dbv2 日期适配层) + result = client.call({ + "_route": "execute", + "action": "select", + "table": "Users", + "columns": ["id", "username", "create_at"], + "where": {"create_at": {"lt": "2999-01-01T00:00:00"}}, + "limit": 1, + }) + assert_true( + result.get("success") is True, + "pymsg-日期列where-ISO字符串", c, + f"error={result.get('error', '')}", + ) + + # 场景 6: 日期列 where — $now 哨兵 + result = client.call({ + "_route": "execute", + "action": "select", + "table": "Users", + "columns": ["id", "username", "create_at"], + "where": {"create_at": {"lt": "$now"}}, + "limit": 1, + }) + assert_true( + result.get("success") is True, + "pymsg-日期列where-$now哨兵", c, + f"error={result.get('error', '')}", + ) + + # 场景 7: 日期列 where — 非法日期字符串应报错 + result = client.call({ + "_route": "execute", + "action": "select", + "table": "Users", + "where": {"create_at": {"lt": "not-a-date"}}, + }) + assert_true( + result.get("success") is False, + "pymsg-日期列where-非法值-拒绝", c, + ) + # ------------------------------------------------------------------ # 汇总 # ------------------------------------------------------------------