diff --git a/README.md b/README.md index 66b9ddff..77d841ad 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,16 @@ Row-level permissions · multi-tab that keeps state · approval workflow · an A |---|---| | **Access & org** | RBAC (menus / buttons / data scopes) · row-level data permissions · department tree · users & roles | | **Import & export** | server-side xlsx / csv export (current filter or selected rows · large exports run in the background and notify you when done · formula-injection safe) · generic import (templates with dropdowns · preview & validation · insert only / upsert / update only · error report · all-or-nothing) · build a whole department tree from one file | -| **Sign-in & security** | captcha · lockout · password history & expiry · OAuth2 (GitHub / Google) · force sign-out of sessions | -| **Workspace** | multi-tab (pin / drag / reload) · command palette ⌘K · shortcuts · guided tour · theme / radius / tab style · per-user time zone · Chinese & English | +| **Sign-in & security** | captcha · lockout · password history & expiry · SMS code sign-in · two-factor auth (TOTP + recovery codes) · OAuth2 (GitHub / Google) · session management (idle timeout + absolute limit · per-client session caps · tells users why they were signed out · force sign-out) | +| **Workspace** | customizable dashboard (cards appear by permission · layout saved per user on the server) · multi-tab (pin / drag / reload) · command palette ⌘K · shortcuts · guided tour · theme / radius / tab style · per-user time zone · Chinese & English | | **Files & storage** | file manager with previews (pdf / docx / xlsx / images / archives) · local + S3-compatible storage · pre-signed downloads · recycle bin · instant upload · chunked & resumable uploads · attachment panel for any page | -| **Approval workflow** | form designer · flow tree (approve / CC / conditional branches) · approve / reject / transfer / send back / take back / add approvers · timeout reminders and auto-actions · drafts · urgent · handover | +| **Approval workflow** | form designer · flow tree (approve / CC / conditional branches) · approve / reject / transfer / send back / take back / add approvers · timeout reminders and auto-actions · drafts · urgent · handover · AI pre-review / form filling / AI approval nodes | | **AI assistant** | full-page chat + side drawer · tool calls bound by permissions and data scopes · preview + confirm for writes · audit log · MCP gateway | -| **Open API** | AK/SK-signed endpoints · scopes / IP allow-list / replay protection / rate limits · call logs · incremental sync · Python / Node / Java SDKs | -| **Messages & jobs** | in-app notifications (real-time) · notices (rich text + attachments) · scheduled jobs (online scheduling · cron preview · run history) | +| **Open API** | AK/SK-signed endpoints · scopes / IP allow-list / replay protection / rate limits · call logs · incremental sync · Python / Node / Java SDKs · webhook subscriptions (Standard Webhooks signatures · retries · delivery log · bulk resend) | +| **Messages & notifications** | in-app notifications (real-time) · notices (rich text + attachments · audiences · scheduled publishing · pinned / level / expiry · pop-up after sign-in with required acknowledgement · top banner · sign-in page notices · read stats) · notification routing (email / SMS / Feishu · DingTalk · WeCom group bots · per-event policies · delivery log) · SMS (Alibaba Cloud / Tencent Cloud) | +| **Scheduled jobs** | online scheduling · cron preview · run history | | **Operations** | settings · dictionaries · sign-in / operation logs (redacted) · server & Redis monitoring · new-version prompt | -| **Clients** | Electron desktop (silent printing · local hardware · auto-update) · native Expo app (not a WebView wrapper) | +| **Clients** | Electron desktop (installers for three platforms · silent printing · local hardware · auto-update) · native Expo app (not a WebView wrapper, **preview** — see below) | | **Developers** | component sandbox (every component tweakable and copy-pasteable) · color palette · type-safe API contract generated from OpenAPI | ## Stack @@ -83,6 +84,14 @@ Row-level permissions · multi-tab that keeps state · approval workflow · an A | **Clients** | Electron 42 · Expo 57 / React Native 0.86 | | **Tooling** | pnpm + turbo monorepo · uv · ruff · pytest · Playwright · GitHub Actions | +## Mobile app (preview) + +The Expo app is shipped as a **preview** in 1.0 — it's real and usable, but not yet covered by the 1.0 compatibility promises. + +- **Has:** home · notices · notifications · users · profile · server monitoring · time zone · SMS code sign-in · session expiry reasons +- **Not yet:** approvals · roles / departments / menus · files · operation & sign-in logs (the Apps screen lists them as "not built yet" rather than hiding them) · binding a phone number or turning on two-factor auth from the app +- **Not yet verified:** no automated tests ([#137](https://github.com/yilecoding/fastapi-react-admin/issues/137)), most screens not run on a real device, no release pipeline + ## Databases Switch with `DATABASE_TYPE` in `.env` — same code, same migration chain, seed data for all three. diff --git a/README.zh-CN.md b/README.zh-CN.md index 37d6197a..8ae9d8e2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,15 +65,16 @@ |---|---| | **权限与组织** | RBAC(菜单 / 按钮 / 数据范围)· 行级数据权限 · 部门树 · 用户与角色 | | **导入导出** | 服务端导出 xlsx / csv(按当前筛选或勾选的行 · 大批量转后台、完成发站内消息 · 防公式注入)· 通用导入(带下拉的模板 · 预览校验 · 只新增 / 新增并更新 / 只更新 · 错误报告 · 全部成功才导入)· 一份文件建整棵部门树 | -| **登录与安全** | 图形验证码 · 失败锁定 · 密码历史与过期 · OAuth2(GitHub / Google)· 在线会话强制下线 | -| **工作区** | 多页签(固定 / 拖拽 / 重新加载)· 命令面板 ⌘K · 快捷键 · 功能引导 · 列表列显隐与拖拽排序(写进地址栏)· 主题色 / 圆角 / 页签外观 · 每人自己的时区 · 中英双语 | +| **登录与安全** | 图形验证码 · 失败锁定 · 密码历史与过期 · 手机验证码登录 · 二次验证(TOTP + 恢复码)· OAuth2(GitHub / Google)· 会话管理(空闲超时 + 绝对上限 · 按端限制会话数 · 被踢下线说明原因 · 强制下线) | +| **工作区** | 可编辑布局的工作台(卡片按权限出现 · 布局按人存服务端)· 多页签(固定 / 拖拽 / 重新加载)· 命令面板 ⌘K · 快捷键 · 功能引导 · 列表列显隐与拖拽排序(写进地址栏)· 主题色 / 圆角 / 页签外观 · 每人自己的时区 · 中英双语 | | **文件与存储** | 文件管理器 + 在线预览(pdf / docx / xlsx / 图片 / 压缩包)· 本地 + S3 兼容存储 · 预签名直连下载 · 回收站 · 秒传 · 分片上传与断点续传 · 可嵌进任何页面的附件面板 | -| **审批流** | 表单设计器 · 流程树(审批 / 抄送 / 条件分支)· 同意 / 驳回 / 转交 / 退回 / 拿回 / 加签 · 超时提醒与自动处理 · 草稿 · 加急 · 离职交接 | +| **审批流** | 表单设计器 · 流程树(审批 / 抄送 / 条件分支)· 同意 / 驳回 / 转交 / 退回 / 拿回 / 加签 · 超时提醒与自动处理 · 草稿 · 加急 · 离职交接 · AI 预审 / 帮填表单 / AI 审批节点 | | **AI 助手** | 整页对话 + 侧滑抽屉 · 受权限与数据范围约束的工具调用 · 写操作预演 + 确认 · 调用审计 · MCP 网关 | -| **能力开放** | AK/SK 签名的开放接口 · scope / IP 白名单 / 防重放 / 限流 · 调用日志 · 增量同步 · Python / Node / Java SDK | -| **消息与任务** | 站内消息(实时推送)· 通知公告(富文本 + 附件)· 定时任务(在线调度 · cron 预览 · 执行记录) | +| **能力开放** | AK/SK 签名的开放接口 · scope / IP 白名单 / 防重放 / 限流 · 调用日志 · 增量同步 · Python / Node / Java SDK · Webhook 事件订阅(Standard Webhooks 签名 · 失败重试 · 投递记录 · 批量重发) | +| **消息与通知** | 站内消息(实时推送)· 通知公告(富文本 + 附件 · 发布范围 · 定时发布 · 置顶 / 级别 / 有效期 · 登录后弹窗与必须确认 · 顶部横幅 · 登录页公告 · 阅读统计)· 通知渠道路由(邮件 / 短信 / 飞书·钉钉·企业微信群机器人 · 按事件配策略 · 投递记录)· 短信(阿里云 / 腾讯云) | +| **定时任务** | 在线调度 · cron 预览 · 执行记录 | | **运维** | 参数配置 · 数据字典 · 登录 / 操作日志(自动脱敏)· 服务器与 Redis 监控 · 发新版提示 | -| **多端** | Electron 桌面端(静默打印 · 本地硬件 · 自动更新)· Expo 原生移动 App(不是 WebView 套壳) | +| **多端** | Electron 桌面端(三平台安装包 · 静默打印 · 本地硬件 · 自动更新)· Expo 原生移动 App(不是 WebView 套壳,**预览版**,见下方说明) | | **开发者** | 组件沙箱(每个组件可调、可抄)· 调色盘 · 由 OpenAPI 生成的类型安全前后端契约 | ## 技术栈 @@ -86,6 +87,14 @@ | **多端** | Electron 42 · Expo 57 / React Native 0.86 | | **工程** | pnpm + turbo 单仓 · uv · ruff · pytest · Playwright · GitHub Actions | +## 移动端(预览版) + +Expo App 在 1.0 里是**预览版**:真的能用,但不在 1.0 的兼容承诺范围内。 + +- **已有**:首页 · 公告 · 消息 · 用户 · 个人中心 · 服务器监控 · 时区 · 手机验证码登录 · 会话结束原因提示 +- **还没有**:审批 · 角色 / 部门 / 菜单 · 文件 · 操作日志与登录日志(「应用」页明写「还没做」,不藏起来)· 在 App 里绑定手机号 / 开二次验证 +- **还没验证**:没有自动化测试([#137](https://github.com/yilecoding/fastapi-react-admin/issues/137))· 多数页面没上过真机 · 没有发版流水线 + ## 数据库:三选一 改 `.env` 里的 `DATABASE_TYPE` 就能换,同一套代码、同一条迁移链,三种库各有一份初始数据。 diff --git a/apps/api/backend/__init__.py b/apps/api/backend/__init__.py index 79ec5bbd..85caca90 100644 --- a/apps/api/backend/__init__.py +++ b/apps/api/backend/__init__.py @@ -17,4 +17,4 @@ def _register_model_globals() -> None: # 产品版本号,/docs 顶上的徽章、AI 的 system_info 工具、OTel 的 service.version 都读它。 # 🔴 必须和 `apps/web/package.json` 的 version 同值 —— 打 tag 时 release.yml 两边一起校验,不一致第一个 job 就红。 # 原来写的是上游 FBA 的版本(1.15.1),/docs 上一直挂着一个和本产品无关的号;上游基线记在 `apps/api/.upstream-baseline` -__version__ = '0.13.0' +__version__ = '1.0.0' diff --git a/apps/api/backend/common/model.py b/apps/api/backend/common/model.py index 349bf3fb..7cd982b0 100644 --- a/apps/api/backend/common/model.py +++ b/apps/api/backend/common/model.py @@ -143,7 +143,8 @@ def process_bind_param(self, value: datetime | None, dialect) -> datetime | None # 「已经是应用时区的墙钟时间」,只需要补时区标记,不需要换算。 value = value.replace(tzinfo=timezone.tz_info) elif value is not None and value.utcoffset() != timezone.now().utcoffset(): - # TODO 处理夏令时偏移 + # 夏令时不用另外处理:astimezone 按「那个时刻」查规则,冬天的值夏天写入也还是同一时刻、 + # 带当时的偏移。唯一缺口是 MySQL 秋季回拨那一小时(DATETIME 不存偏移),见 tests/test_timezone_dst.py value = timezone.from_datetime(value) return value diff --git a/apps/api/backend/tests/test_timezone_dst.py b/apps/api/backend/tests/test_timezone_dst.py new file mode 100644 index 00000000..21e0daaa --- /dev/null +++ b/apps/api/backend/tests/test_timezone_dst.py @@ -0,0 +1,86 @@ +"""`TimeZone` 遇到夏令时(#200:model.py 里那句「TODO 处理夏令时偏移」到底要不要处理) + +结论:**带时区的值不会被夏令时搞错**。那条分支是「偏移和*现在*不一样就 `astimezone(应用时区)`」—— +冬天的值在夏天写入时偏移确实不同,但 `astimezone` 按**那个时刻**查规则,换算出来还是同一个时刻、 +带着当时正确的偏移,没有平移一小时。所以 TODO 不需要「处理」,这里把它钉成测试。 + +唯一的缺口在 **MySQL**(`DATETIME` 不存偏移,只存应用时区的墙上时间):秋季回拨那一小时的墙上时间 +出现两次,读回来只能当成第一次(`fold=0`),第二次的记录会早一小时。PostgreSQL / SQL Server +存的是带偏移的时刻,不受影响。见最后那条 xfail。 + +⚠️ 顺带实测到一个 Python 的坑:**同一个 ZoneInfo 的两个 aware datetime 相减是按墙上时间减的**, +跨夏令时差一小时。算时长要先 `.astimezone(UTC)`(PostgreSQL / SQL Server 驱动读回来本来就是 +UTC 或固定偏移,不中招;MySQL 读回来被补上应用时区,会中招)。 + +应用时区刻意设成有夏令时的 `America/New_York`(默认的 `Asia/Shanghai` 没有夏令时,什么都测不出来)。 +""" + +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from backend.common.model import TimeZone +from backend.utils.timezone import timezone + +NY = ZoneInfo('America/New_York') +SUMMER_NOW = datetime(2026, 7, 1, 12, 0, tzinfo=NY) # EDT,-04:00 + + +@pytest.fixture +def ny(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(timezone, 'tz_info', NY) + monkeypatch.setattr(timezone, 'now', lambda: SUMMER_NOW) + + +def bind(value: datetime) -> datetime: + return TimeZone().process_bind_param(value, None) + + +def test_winter_value_written_in_summer_keeps_its_instant_and_offset(ny: None) -> None: + """🔴 那条 TODO 分支:冬天(-05:00)的值、夏天(-04:00)写入,不能被平移一小时""" + winter = datetime(2026, 1, 15, 9, 0, tzinfo=NY) + out = bind(winter) + assert out == winter # 同一个时刻 + assert out.utcoffset() == timedelta(hours=-5) # 偏移是当时的,不是「现在」的 + + +def test_utc_value_is_converted_to_the_offset_of_that_date(ny: None) -> None: + out = bind(datetime(2026, 1, 15, 14, 0, tzinfo=UTC)) + assert out == datetime(2026, 1, 15, 14, 0, tzinfo=UTC) + assert (out.hour, out.utcoffset()) == (9, timedelta(hours=-5)) + + +@pytest.mark.parametrize( + ('naive', 'offset'), + [ + (datetime(2026, 1, 15, 9, 0, tzinfo=NY).replace(tzinfo=None), -5), + (datetime(2026, 7, 15, 9, 0, tzinfo=NY).replace(tzinfo=None), -4), + ], + ids=['winter', 'summer'], +) +def test_naive_value_gets_the_offset_of_its_own_date(ny: None, naive: datetime, offset: int) -> None: + """naive 值当作应用时区的墙上时间,偏移按它自己那天算""" + assert bind(naive).utcoffset() == timedelta(hours=offset) + + +def test_spring_forward_instants_round_trip(ny: None) -> None: + """春季拨快前后各一分钟(01:59 EST → 03:00 EDT),时刻都不变""" + before = datetime(2026, 3, 8, 6, 59, tzinfo=UTC) + after = datetime(2026, 3, 8, 7, 0, tzinfo=UTC) + assert bind(before) == before + assert bind(after) == after + # ⚠️ 不能直接 `bind(after) - bind(before)`:同一个 ZoneInfo 的两个值相减,Python 按**墙上时间**减、 + # 不看偏移,跨夏令时会算出 61 分钟(实测)。算时长先转 UTC + assert bind(after).astimezone(UTC) - bind(before).astimezone(UTC) == timedelta(minutes=1) + + +@pytest.mark.xfail( + strict=True, + reason='MySQL 的 DATETIME 不存偏移:秋季回拨那一小时读回来一律当第一次(fold=0),第二次会早一小时', +) +def test_mysql_fall_back_second_occurrence_round_trip(ny: None) -> None: + second = datetime(2026, 11, 1, 6, 30, tzinfo=UTC) # 01:30 EST,回拨后第二次的 01:30 + wall = bind(second).replace(tzinfo=None) # MySQL 存的就是这个墙上时间 + back = TimeZone().process_result_value(wall, None) + assert back == second diff --git a/apps/api/package.json b/apps/api/package.json index 5dcf602c..b26bf901 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "0.13.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "uv run python -m uvicorn backend.main:app --port 8088 --reload --reload-dir backend", diff --git a/apps/docs/src/content/docs/en/reference/upgrade.md b/apps/docs/src/content/docs/en/reference/upgrade.md index 30468d93..26e76bd4 100644 --- a/apps/docs/src/content/docs/en/reference/upgrade.md +++ b/apps/docs/src/content/docs/en/reference/upgrade.md @@ -9,7 +9,9 @@ How you upgrade depends on how you use the project. If you **deploy the publishe ## How are versions numbered? -Semver: a big feature bumps the minor version (0.11 → 0.12), fixes only bump the patch (0.9.0 → 0.9.1). It is still 0.x, but the compatibility rules below are already in force. +Semver: breaking any of the compatibility rules below bumps the major version (1.x → 2.0), a big feature bumps the minor version (1.0 → 1.1), fixes only bump the patch (1.0.0 → 1.0.1). From 1.0.0 these rules are formally in force. + +The mobile app is a **preview** in 1.0 and is not covered by these rules (see "Mobile app (preview)" in the README for what it has and lacks). ## What is promised not to break? diff --git a/apps/docs/src/content/docs/reference/upgrade.md b/apps/docs/src/content/docs/reference/upgrade.md index 3ec167aa..7f279888 100644 --- a/apps/docs/src/content/docs/reference/upgrade.md +++ b/apps/docs/src/content/docs/reference/upgrade.md @@ -9,7 +9,9 @@ sidebar: ## 版本号怎么定 -按 semver:合进一个大功能升次版本号(0.11 → 0.12),只有修复升修订号(0.9.0 → 0.9.1)。现在还是 0.x,但已经按下面的兼容约定执行。 +按 semver:破坏下面兼容约定里的任何一条升主版本号(1.x → 2.0),合进一个大功能升次版本号(1.0 → 1.1),只有修复升修订号(1.0.0 → 1.0.1)。1.0.0 起正式按这套约定执行。 + +移动端 App 在 1.0 里是**预览版**,不在兼容约定范围内(已有 / 还没有的功能见 README「移动端(预览版)」)。 ## 哪些东西承诺不破坏 diff --git a/apps/web/package.json b/apps/web/package.json index c577092f..a83092f4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.13.0", + "version": "1.0.0", "type": "module", "private": true, "scripts": { diff --git a/apps/worker/package.json b/apps/worker/package.json index 48ddd65c..36261224 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -1,6 +1,6 @@ { "name": "worker", - "version": "0.13.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "cd ../api && CELERY_CUSTOM_WORKER_POOL=celery_aio_pool.pool:AsyncIOPool uv run --no-sync celery -A backend.app.task.celery worker -B -l info --pool=custom" diff --git a/package.json b/package.json index 35d58192..4c4e9970 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fastapi-react-admin", - "version": "0.13.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "turbo dev --filter=!@admin/desktop",