create-dwy 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +1 -1
  3. package/src/prompt-style.js +82 -0
  4. package/src/searchable-select.js +12 -29
  5. package/src/tab-multiselect.js +7 -12
  6. package/templates/ai-tools/rules/Python/dwy-python-base-lib.md +1 -1
  7. package/templates/ai-tools/skills/Python/dwy-eapi/SKILL.md +59 -200
  8. package/templates/ai-tools/skills/Python/dwy-eapi/shared/anticorruption.md +13 -0
  9. package/templates/ai-tools/skills/Python/dwy-eapi/shared/layering.md +5 -0
  10. package/templates/ai-tools/skills/Python/dwy-eapi/shared/response-envelope.md +20 -0
  11. package/templates/ai-tools/skills/Python/dwy-eapi/shared/timezone.md +7 -0
  12. package/templates/ai-tools/skills/Python/dwy-eapi/v0/breaking.md +12 -0
  13. package/templates/ai-tools/skills/Python/dwy-eapi/v0/email-canonical.md +18 -0
  14. package/templates/ai-tools/skills/Python/dwy-eapi/v0/index.md +103 -0
  15. package/templates/ai-tools/skills/Python/dwy-eapi/v0/providers-email.md +39 -0
  16. package/templates/ai-tools/skills/Python/dwy-eapi/versions.yml +13 -0
  17. package/templates/ai-tools/skills/Vue/dwy-eui/SKILL.md +62 -306
  18. package/templates/ai-tools/skills/Vue/dwy-eui/references/eui-design-guide.md +8 -1
  19. package/templates/ai-tools/skills/Vue/dwy-eui/references/eui-integration-guide.md +0 -31
  20. package/templates/ai-tools/skills/Vue/dwy-eui/shared/config-theme.md +8 -0
  21. package/templates/ai-tools/skills/Vue/dwy-eui/shared/form.md +6 -0
  22. package/templates/ai-tools/skills/Vue/dwy-eui/shared/overlay.md +6 -0
  23. package/templates/ai-tools/skills/Vue/dwy-eui/shared/reka.md +5 -0
  24. package/templates/ai-tools/skills/Vue/dwy-eui/v1/index.md +8 -0
  25. package/templates/ai-tools/skills/Vue/dwy-eui/v2/breaking.md +14 -0
  26. package/templates/ai-tools/skills/Vue/dwy-eui/v2/index.md +55 -0
  27. package/templates/ai-tools/skills/Vue/dwy-eui/v2/lookup.md +29 -0
  28. package/templates/ai-tools/skills/Vue/dwy-eui/versions.yml +12 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # create-dwy
2
2
 
3
+ ## 0.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 上下移动时当前条目用指针 + 反底标出。原先只靠 dim,已勾选或 JetBrains 终端里看不出光标。
8
+
9
+ ---
10
+
3
11
  ## 0.19.0
4
12
 
5
13
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dwy",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "项目脚手架与多平台 AI 配置同步工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * CLI 列表行聚焦样式。
3
+ * 只靠 dim 在 JetBrains / 部分终端看不出光标;已勾选时勾选框又全是绿块。
4
+ * 指针字符不依赖颜色,反底在支持 ANSI 的终端再加强。
5
+ */
6
+
7
+ import { styleText } from 'node:util'
8
+ import process from 'node:process'
9
+
10
+ /** 是否支持 unicode 符号(与 clack prompts 判定一致) */
11
+ function isUnicodeSupported() {
12
+ if (process.platform !== 'win32') {
13
+ return process.env.TERM !== 'linux'
14
+ }
15
+ return Boolean(process.env.CI)
16
+ || Boolean(process.env.WT_SESSION)
17
+ || Boolean(process.env.TERMINUS_SUBLIME)
18
+ || process.env.ConEmuTask === '{cmd::Cmder}'
19
+ || process.env.TERM_PROGRAM === 'Terminus-Sublime'
20
+ || process.env.TERM_PROGRAM === 'vscode'
21
+ || process.env.TERM === 'xterm-256color'
22
+ || process.env.TERM === 'alacritty'
23
+ || process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm'
24
+ }
25
+
26
+ const unicode = isUnicodeSupported()
27
+ const unicodeOr = (c, fallback) => (unicode ? c : fallback)
28
+
29
+ /** 光标指针。无 unicode 时用 ASCII,保证去 ANSI 后仍能看出聚焦行。 */
30
+ const S_POINTER = unicodeOr('›', '>')
31
+ const S_RADIO_ACTIVE = unicodeOr('●', '>')
32
+ const S_RADIO_INACTIVE = unicodeOr('○', ' ')
33
+ const S_CHECKBOX_ACTIVE = unicodeOr('◻', '[•]')
34
+ const S_CHECKBOX_SELECTED = unicodeOr('◼', '[+]')
35
+ const S_CHECKBOX_INACTIVE = unicodeOr('◻', '[ ]')
36
+
37
+ /**
38
+ * 聚焦文案:反底芯片。无色终端退回原文字,靠指针区分。
39
+ *
40
+ * @param {string} label
41
+ * @param {boolean} active
42
+ */
43
+ function formatLabel(label, active) {
44
+ if (active) return styleText(['bgCyan', 'black'], ` ${label} `)
45
+ return styleText('dim', label)
46
+ }
47
+
48
+ /**
49
+ * 多选一行。
50
+ * selected=已勾;active=当前光标。disabled 时不响应聚焦样式。
51
+ *
52
+ * @param {{ label: string, selected?: boolean, active?: boolean, prefix?: string, disabled?: boolean }} opts
53
+ */
54
+ export function formatCheckboxRow({ label, selected = false, active = false, prefix = '', disabled = false }) {
55
+ const prefixPart = prefix ? `${prefix} ` : ''
56
+ if (disabled) {
57
+ return ` ${styleText('dim', S_CHECKBOX_INACTIVE)} ${prefixPart}${styleText(['strikethrough', 'dim'], label)}`
58
+ }
59
+ const pointer = active ? styleText('cyan', S_POINTER) : ' '
60
+ const box = selected
61
+ ? styleText('green', S_CHECKBOX_SELECTED)
62
+ : active
63
+ ? styleText('cyan', S_CHECKBOX_ACTIVE)
64
+ : styleText('dim', S_CHECKBOX_INACTIVE)
65
+ return `${pointer} ${box} ${prefixPart}${formatLabel(label, active)}`
66
+ }
67
+
68
+ /**
69
+ * 单选一行。disabled 画删除线,不画指针。
70
+ *
71
+ * @param {{ label: string, active?: boolean, disabled?: boolean }} opts
72
+ */
73
+ export function formatRadioRow({ label, active = false, disabled = false }) {
74
+ if (disabled) {
75
+ return ` ${styleText('gray', S_RADIO_INACTIVE)} ${styleText(['strikethrough', 'gray'], label)}`
76
+ }
77
+ const pointer = active ? styleText('cyan', S_POINTER) : ' '
78
+ const radio = active
79
+ ? styleText('green', S_RADIO_ACTIVE)
80
+ : styleText('dim', S_RADIO_INACTIVE)
81
+ return `${pointer} ${radio} ${formatLabel(label, active)}`
82
+ }
@@ -8,6 +8,7 @@ import { AutocompletePrompt, getColumns, settings } from '@clack/core'
8
8
  import { limitOptions } from '@clack/prompts'
9
9
  import { styleText } from 'node:util'
10
10
  import process from 'node:process'
11
+ import { formatCheckboxRow, formatRadioRow } from './prompt-style.js'
11
12
 
12
13
  /** 是否支持 unicode 符号(与 clack prompts 判定一致) */
13
14
  function isUnicodeSupported() {
@@ -34,10 +35,6 @@ const S_STEP_ERROR = unicodeOr('▲', 'x')
34
35
  const S_STEP_SUBMIT = unicodeOr('◇', 'o')
35
36
  const S_BAR = unicodeOr('│', '|')
36
37
  const S_BAR_END = unicodeOr('└', '—')
37
- const S_RADIO_ACTIVE = unicodeOr('●', '>')
38
- const S_RADIO_INACTIVE = unicodeOr('○', ' ')
39
- const S_CHECKBOX_SELECTED = unicodeOr('◼', '[+]')
40
- const S_CHECKBOX_INACTIVE = unicodeOr('◻', '[ ]')
41
38
 
42
39
  /** 底部说明区固定行数,避免列表高度随描述长短跳动 */
43
40
  const DESC_PANEL_LINES = 3
@@ -256,21 +253,12 @@ export function searchableMultiselect(opts) {
256
253
  maxItems: opts.maxItems,
257
254
  output: opts.output,
258
255
  rowPadding: header.length + footer.length,
259
- style: (opt, active) => {
260
- const selected = this.selectedValues.includes(opt.value)
261
- const label = optionLabel(opt)
262
- const box = selected
263
- ? styleText('green', S_CHECKBOX_SELECTED)
264
- : styleText('dim', S_CHECKBOX_INACTIVE)
265
- if (opt.disabled) {
266
- return `${styleText('dim', S_CHECKBOX_INACTIVE)} ${styleText(['strikethrough', 'dim'], label)}`
267
- }
268
- // 聚焦也不拼 hint,描述只在底部公共区
269
- if (active) {
270
- return `${box} ${label}`
271
- }
272
- return `${box} ${styleText('dim', label)}`
273
- },
256
+ style: (opt, active) => formatCheckboxRow({
257
+ label: optionLabel(opt),
258
+ selected: this.selectedValues.includes(opt.value),
259
+ active,
260
+ disabled: opt.disabled,
261
+ }),
274
262
  })
275
263
 
276
264
  return [
@@ -396,16 +384,11 @@ export function searchableSelect(opts) {
396
384
  output: opts.output,
397
385
  columnPadding: withGuide ? 3 : 0,
398
386
  rowPadding: header.length + footer.length,
399
- style: (opt, active) => {
400
- const label = optionLabel(opt)
401
- if (opt.disabled) {
402
- return `${styleText('gray', S_RADIO_INACTIVE)} ${styleText(['strikethrough', 'gray'], label)}`
403
- }
404
- if (active) {
405
- return `${styleText('green', S_RADIO_ACTIVE)} ${label}`
406
- }
407
- return `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', label)}`
408
- },
387
+ style: (opt, active) => formatRadioRow({
388
+ label: optionLabel(opt),
389
+ active,
390
+ disabled: opt.disabled,
391
+ }),
409
392
  })
410
393
 
411
394
  return [
@@ -8,6 +8,7 @@ import { Prompt, getColumns, settings } from '@clack/core'
8
8
  import { limitOptions } from '@clack/prompts'
9
9
  import { styleText } from 'node:util'
10
10
  import process from 'node:process'
11
+ import { formatCheckboxRow } from './prompt-style.js'
11
12
 
12
13
  /** 是否支持 unicode 符号(与 clack / searchable-select 判定一致) */
13
14
  function isUnicodeSupported() {
@@ -35,8 +36,6 @@ const S_STEP_SUBMIT = unicodeOr('◇', 'o')
35
36
  const S_BAR = unicodeOr('│', '|')
36
37
  const S_BAR_END = unicodeOr('└', '—')
37
38
  const S_BAR_H = () => unicodeOr('─', '-')
38
- const S_CHECKBOX_SELECTED = unicodeOr('◼', '[+]')
39
- const S_CHECKBOX_INACTIVE = unicodeOr('◻', '[ ]')
40
39
  const S_TAB_MORE = unicodeOr('‹', '<')
41
40
  const S_TAB_MORE_RIGHT = unicodeOr('›', '>')
42
41
 
@@ -418,16 +417,12 @@ export function tabMultiselect(opts) {
418
417
  maxItems: opts.maxItems ?? 12,
419
418
  output: opts.output,
420
419
  rowPadding: header.length + footer.length,
421
- style: (opt, active) => {
422
- const on = selected.includes(opt.value)
423
- const label = opt.label ?? String(opt.value ?? '')
424
- const box = on
425
- ? styleText('green', S_CHECKBOX_SELECTED)
426
- : styleText('dim', S_CHECKBOX_INACTIVE)
427
- const prefix = formatItemTypePrefix(opt.type)
428
- if (active) return `${box} ${prefix} ${label}`
429
- return `${box} ${prefix} ${styleText('dim', label)}`
430
- },
420
+ style: (opt, active) => formatCheckboxRow({
421
+ label: opt.label ?? String(opt.value ?? ''),
422
+ selected: selected.includes(opt.value),
423
+ active,
424
+ prefix: formatItemTypePrefix(opt.type),
425
+ }),
431
426
  })
432
427
 
433
428
  return [
@@ -33,7 +33,7 @@ paths:
33
33
 
34
34
  ### 查阅方式(关键)
35
35
 
36
- **在编写任何后端代码前,以及调用任何 dwyeapi 提供的 API 之前,必须先用 `Skill` 工具加载 `dwy-eapi` skill 查阅当前最新接口。** 本文件**只规定约束与边界**,**不固化** dwyeapi 的具体模块路径、类名、函数签名、参数 —— 这些会随版本演进,固化在 rules 里会与实际包不同步。
36
+ **在编写任何后端代码前,以及调用任何 dwyeapi 提供的 API 之前,必须先用 `Skill` 工具加载 `dwy-eapi`。** skill 会按消费方**实际安装版本**选 `v0`/`v1` 文档线;不确定时先 clone `dwyeapi@x.y.z`,拿不到再读虚拟环境里的安装包。本文件**只规定约束与边界**,**不固化**模块路径、类名、函数签名。
37
37
 
38
38
  ```
39
39
  Skill 工具调用:skill="dwy-eapi"
@@ -1,226 +1,85 @@
1
1
  ---
2
2
  name: dwy-eapi
3
- description: "dwyeapi FastAPI 后端基础设施使用指南。涉及以下任何主题,**必须**使用此 skill(即使用户没有明确说 'dwyeapi' / 'eapi'):FastAPI 项目搭建 / Pydantic Settings 配置 / 异步 SQLAlchemy / Redis 缓存 / JWT 认证 / bcrypt 密码哈希 / 异常体系(AppError / NotFoundError / BusinessError)/ 统一响应信封(ApiResponse / PageData)/ 分页 / 日志(loguru)/ 健康检查 / PII 脱敏 / 时区敏感的时间处理 / 异步任务(ARQ)/ 邮件验证码发送。本 skill 是 @dwydev/eapi 的唯一权威导航来源。"
4
- eapi_baseline_version: "0.9.0"
3
+ description: "Use when building or changing FastAPI backends that use dwyeapi: Pydantic Settings, async SQLAlchemy, Redis, JWT/bcrypt, AppError/NotFoundError/BusinessError, ApiResponse/PageData, pagination, loguru, health checks, PII masking, timezone-aware dt, ARQ tasks, email verification codes, Gmail alias canonicalization. The single navigation skill for dwyeapi."
5
4
  ---
6
5
 
7
- # dwyeapi 后端基础设施使用指南
6
+ # dwyeapi
8
7
 
9
- Python 3.11+ FastAPI 基础设施包,全异步。单文件模块设计(每个能力一个 .py),便于直接读源码。
8
+ FastAPI 基础设施包。本文件只做**路由**:探测安装版本 → 选文档线 → 不够再读源码(先 clone tag,拿不到再读虚拟环境)。
10
9
 
11
- 本 skill 索引基于 dwyeapi **0.9.0**。消费方版本可能不同 — 见 [版本兼容规则](#版本兼容规则)。
10
+ 对外只有这一个 skill。`v0/` `v1/` 不是独立 skill,禁止当多个入口用。
12
11
 
13
- ## 模块清单(13 个)
12
+ ## 每次写 dwyeapi 代码前(强制)
14
13
 
15
- | 模块 | 用途 |
16
- |------|------|
17
- | `config` | Pydantic BaseSettings + 运行环境识别 (is_dev / is_prod / get_environment) |
18
- | `exceptions` | AppError 异常体系 + FastAPI 全局 handler 注册 |
19
- | `database` | 异步 SQLAlchemy engine/session 工厂 + Base + TimestampMixin |
20
- | `dependencies` | FastAPI 依赖注入工厂(如 get_db) |
21
- | `security` | JWT 令牌 + bcrypt 密码哈希(无状态) |
22
- | `cache` | 异步 Redis 连接管理(懒单例) |
23
- | `response` | 统一 API 响应信封 `ApiResponse[T]` + `PageData[T]` |
24
- | `pagination` | 分页参数 PaginationParams + offset/limit 计算 |
25
- | `dt` | 中国时区敏感的全局时间工具(替代 datetime.now) |
26
- | `logger` | loguru 全局日志(按天 + 按大小轮转、stdlib 拦截) |
27
- | `masking` | PII 数据脱敏(手机/邮箱/身份证/银行卡/姓名/地址/IP/车牌) |
28
- | `health` | 健康检查路由工厂(只探活、不探依赖) |
29
- | `tasks` | 异步任务系统(基于 ARQ,需 `pip install dwyeapi[tasks]`) |
30
- | `providers.email` | 邮件验证码发送(可插拔,默认 Resend,可继承注入腾讯 SES / SMTP 等) |
31
-
32
- ---
33
-
34
- ## 查 API 标准动作(核心:每次写 dwyeapi 代码前都做)
35
-
36
- dwyeapi 的源码 docstring 写得非常详尽(含设计意图、边界行为、Args/Returns/Raises、典型陷阱)。**本文档不再镜像签名表格** —— 因为表格信息密度低于源码 docstring,且会随版本漂移。需要某个 API 的精确签名/语义时,按下面顺序拿:
37
-
38
- ### 第一步:定位 dwyeapi 真实版本和安装路径
39
-
40
- ```bash
41
- # 拿版本
42
- pip show dwyeapi | grep -i version
43
- # 或:python -c "import dwyeapi; print(dwyeapi.__version__)"
44
-
45
- # 拿包安装目录
46
- python -c "import dwyeapi; print(dwyeapi.__file__)"
47
- # 输出形如 /path/to/.venv/lib/python3.12/site-packages/dwyeapi/__init__.py
48
- # dirname 后的目录就是 dwyeapi 包根,模块都在里面
49
- ```
50
-
51
- ### 第二步:读模块源码(按存在性回退)
52
-
53
- ```
54
- a. 优先:<site-packages>/dwyeapi/{module}.py
55
- —— 下游消费方默认场景。每个模块单文件,含模块级 docstring + 所有公开函数/类的 Args/Returns/Raises
56
-
57
- b. 次选:<dwy-shared-root>/backend/src/dwyeapi/{module}.py
58
- —— 仅当在 dwy-shared monorepo 内或并列 clone 时可用,内容相同
59
-
60
- c. 都拿不到 → 退到本文档下方的 [模块清单](#模块清单13-个)(粗略,仅做导航)
61
- ```
62
-
63
- ### 第三步:tasks 模块查详细文档
64
-
65
- tasks 模块功能丰富(注册 / 提交 / 查询 / 取消 / 进度),单独维护一份完整集成指南:
66
-
67
- ```
68
- references/tasks-integration-guide.md
69
- ```
70
-
71
- 只在用到 tasks 时读,平时不必加载。
72
-
73
- ### 第四步:冲突时永远信源码
74
-
75
- 本 SKILL.md 是导航 + 心智模型,**不是 API 真相**。当文档描述与 site-packages 中的 `.py` 不一致:无条件以源码为准。
76
-
77
- ---
14
+ 1. **探测安装版本**(按顺序,命中即停)
15
+ ```bash
16
+ python -c "import dwyeapi; print(dwyeapi.__version__)"
17
+ ```
18
+ 失败则读项目 `pyproject.toml` / `uv.lock` 里 `dwyeapi` 约束。
19
+ 仍失败:按 `v0` 给脚手架,并声明「未探测到安装版本」。
78
20
 
79
- ## 版本兼容规则
21
+ 2. **选线**:读同目录 `versions.yml`。`0.y.z` → `v0/`,`1.y.z` → `v1/`,`2.y.z` → `v2/`。目录不存在(例如装了 1.x 但还没有 `v1/`)→ 不要用 `v0` 冒充;按下面「读源码」拿该版本代码,并说明文档线未建。
80
22
 
81
- - 本 skill `eapi_baseline_version: 0.9.0`,模块清单、踩坑提示都基于这个版本
82
- - 消费方装的是不同版本(更新或更旧)时,**必须**按"查 API 标准动作"流程读 .py,不要默认本文档准确
83
- - dwyeapi < 0.9.0 可能没有 dt / tasks / providers 模块:先 `ls <site-packages>/dwyeapi/` 确认存在再用
84
- - 主版本号变更(0.x → 1.x)时,本文档的索引可能完全失效:先 `ls <site-packages>/dwyeapi/` 重建认知
23
+ 3. **读文档**:先 `shared/`(跨大版本不变),再该线 `vN/`。能力标了版本下限(如 `canonicalize_email` ≥0.9.6)时,安装版本低于下限 → 当不存在。
85
24
 
86
- ---
87
-
88
- ## 重要约束与已知陷阱
89
-
90
- 源码读得出来但容易忽略的"踩坑经验",写后端代码时遵守:
91
-
92
- ### 业务层架构(service / router 分层)
93
-
94
- - **service 层**抛业务异常(`NotFoundError("用户")` / `BusinessError("余额不足", code="INSUFFICIENT_BALANCE")` 等),不感知 HTTP 概念
95
- - **router 层**不写 try/except,由 `register_exception_handlers(app)` 统一转成 `ApiResponse` 信封 JSON
96
- - **禁止**在 service 层抛 `HTTPException` —— 会绕过统一 handler,破坏响应结构一致性
97
-
98
- ### 时间处理(避免时区 bug)
99
-
100
- - 业务时间统一中国时区,**禁止 `datetime.now()`**,统一走 `dwyeapi.dt`
101
- - `dt.now()` 返回 naive datetime(Asia/Shanghai,用于数据库存储)
102
- - `dt.utc_now()` 返回 aware UTC datetime(用于 JWT exp 等协议字段)
103
- - 这两个不能混用:数据库列存 naive 中国时间、JWT exp 存 UTC aware
104
-
105
- ### 配置
106
-
107
- - **没有 `debug` 字段**,所有调试开关统一用 `is_dev()`:避免两个维度(debug + environment)冲突
108
- - `environment` 默认 `"prod"`(误配置时保守):生产环境必须显式关闭 docs/redoc/openapi 三个端点(`docs_url="/docs" if is_dev() else None`),否则路由元信息会泄露给攻击者
109
- - `BaseSettings` 必填三个字段:`database_url` / `redis_url` / `secret_key`(继承时不能给默认值)
110
-
111
- ### Redis 连接
112
-
113
- - `cache.configure(redis_url)` 启动时调用一次(lifespan);`cache.get_redis()` 首次调用懒建立连接
114
- - `close_redis()` 可重复调用(安全幂等),lifespan 关闭阶段调用
115
- - 未 configure 直接 `get_redis()` 会抛 `RuntimeError`
116
-
117
- ### tasks 模块
118
-
119
- - 需要 `pip install dwyeapi[tasks]` 单独装 extra(避免不用任务的项目背 ARQ 依赖)
120
- - 任务函数签名固定 `async def task(ctx: TaskContext, params: dict)`,第一个参数 ctx 由框架注入
121
- - 取消机制是协作式:任务函数内必须主动 `await ctx.is_cancelled()` 检查,否则取消信号无效
122
- - 完整细节读 `references/tasks-integration-guide.md`
25
+ 4. **文档不够 / 和记忆冲突 / 下限对不上** → 读代码,顺序如下(命中即停):
26
+ 1. clone 安装版本对应 tag(见下)
27
+ 2. tag 不存在 / clone 失败 → 读**当前虚拟环境**里已安装的 `dwyeapi` 源码
28
+ 3. 虚拟环境也没有 → **停下来问人**,禁止猜 API,禁止拿旁边 dwy-shared master 冒充
123
29
 
124
- ### 邮件 Provider
30
+ ## 读源码
125
31
 
126
- - 内置仅 `resend`。业务用其他通道(腾讯 SES / SMTP / 自建):继承 `EmailProviderBase` 实现 `_send` → `register_email_provider("name", factory)` → `.env` 设 `EMAIL__PROVIDER=name`
127
- - 验证码生成 + Redis 存储 + 品牌化模板 由基类全部复用,子类只管"发出去"
32
+ ### 1. clone tag(优先)
128
33
 
129
- ### 日志
130
-
131
- - `logger.configure()` 启动时调用一次;`logger.close()` 在 lifespan 结束时调用
132
- - `intercept_stdlib=True`(默认)会把 stdlib logging(uvicorn / SQLAlchemy 等)也接管到统一 sink
133
- - 多进程部署时必须 `enqueue=True`(默认)——避免日志文件竞争写
134
-
135
- ### 健康检查
136
-
137
- - `create_health_router()` **只**探活、**不**触达 PostgreSQL / Redis 等依赖
138
- - 理由:健康端点常对公网开放,内置 dependency check 会被攻击者用来 DoS 放大数据库/缓存
139
- - 需要 readiness 的业务自行实现并限制内网访问
140
-
141
- ---
142
-
143
- ## 典型项目接入流程
144
-
145
- ```python
146
- # config.py
147
- from dwyeapi.config import BaseSettings
148
-
149
- class Settings(BaseSettings):
150
- app_name: str = "My API"
151
- service_name: str = "my-api"
152
-
153
- settings = Settings() # 从 .env / 环境变量读 database_url、redis_url、secret_key
154
-
155
- # database.py
156
- from dwyeapi.database import create_async_engine_factory, create_session_factory
157
- from dwyeapi.dependencies import create_get_db
158
- from dwyeapi import is_dev
159
-
160
- engine = create_async_engine_factory(settings.database_url, echo=is_dev())
161
- session_factory = create_session_factory(engine)
162
- get_db = create_get_db(session_factory)
163
-
164
- # main.py
165
- from contextlib import asynccontextmanager
166
- from fastapi import FastAPI
167
- from dwyeapi import cache, logger, is_dev
168
- from dwyeapi.exceptions import register_exception_handlers
169
- from dwyeapi.health import create_health_router
170
-
171
- @asynccontextmanager
172
- async def lifespan(app: FastAPI):
173
- logger.configure(level="INFO", log_dir="./logs", filename="app")
174
- cache.configure(settings.redis_url)
175
- yield
176
- await cache.close_redis()
177
- logger.close()
178
-
179
- app = FastAPI(
180
- lifespan=lifespan,
181
- docs_url="/docs" if is_dev() else None,
182
- redoc_url="/redoc" if is_dev() else None,
183
- openapi_url="/openapi.json" if is_dev() else None,
184
- )
185
- register_exception_handlers(app)
186
- app.include_router(create_health_router(service_name=settings.service_name, version="1.0.0"))
34
+ ```bash
35
+ VER=0.9.6 # 换成第 1 步探测到的版本
36
+ TAG="dwyeapi@${VER}"
37
+ DEST=".dwy/eapi-src/${VER}" # 项目内缓存,已 gitignore
38
+
39
+ if [ ! -d "$DEST/backend/src/dwyeapi" ]; then
40
+ git clone --depth 1 --branch "$TAG" \
41
+ --filter=blob:none --sparse \
42
+ https://github.com/danweiyuancircle/dwy-ai-experience.git \
43
+ "$DEST"
44
+ git -C "$DEST" sparse-checkout set backend/src/dwyeapi
45
+ fi
46
+ # 只读 $DEST/backend/src/dwyeapi/ 的模块 docstring
187
47
  ```
188
48
 
189
- ---
190
-
191
- ## 响应契约速查(前后端对齐用)
49
+ - `--branch` 必须是 `dwyeapi@x.y.z`,禁止 clone 默认 branch。
50
+ - 必须 sparse,只要 `backend/src/dwyeapi`。
192
51
 
193
- 所有接口返回统一信封:
52
+ ### 2. 虚拟环境(clone 拿不到时)
194
53
 
195
- ```json
196
- {
197
- "code": "SUCCESS",
198
- "message": "success",
199
- "data": { ... },
200
- "timestamp": 1775625000
201
- }
54
+ ```bash
55
+ python -c "import dwyeapi, os; print(os.path.dirname(dwyeapi.__file__))"
56
+ # 输出形如 .venv/lib/python3.11/site-packages/dwyeapi
57
+ # 读该目录下模块 docstring
202
58
  ```
203
59
 
204
- - 成功:`code = "SUCCESS"`,`data` 是业务载荷(单体或 `PageData[T]`)
205
- - 失败:`code` 是业务错误码(如 `NOT_FOUND` / `BUSINESS_ERROR` / `VALIDATION_ERROR` 等),HTTP 状态保持语义(404 就是 404)
206
- - 校验错误 `VALIDATION_ERROR`:`data.errors: [{field, message}]`,field 含 `body.` / `query.` / `path.` 前缀
207
-
208
- 构造响应:用 `ApiResponse.ok(data)` 或 `ApiResponse.page(items, total, page, page_size)`,不直接实例化 `ApiResponse`。
60
+ editable 安装时这个路径就是消费方正在跑的代码,当作安装版本真相。
209
61
 
210
- 错误响应**不要业务代码构造** —— 直接 `raise NotFoundError("用户")`,handler 自动转。
62
+ 文档与源码冲突:信源码。clone 与 venv 都读到且不一致:以 **venv** 为准(editable / 热修),回复里写明。
211
63
 
212
- ---
64
+ ## 文档地图
213
65
 
214
- ## 防腐层硬性约束(写代码前必读)
66
+ | 路径 | 何时读 |
67
+ |------|--------|
68
+ | `shared/layering.md` | 分层、禁 HTTPException |
69
+ | `shared/timezone.md` | 禁 `datetime.now` |
70
+ | `shared/response-envelope.md` | ApiResponse 信封 |
71
+ | `shared/anticorruption.md` | 禁止直调 redis/jwt/logging |
72
+ | `v0/index.md` | 0.x 模块清单、接入、陷阱 |
73
+ | `v0/providers-email.md` | 邮件 Provider 注册表 |
74
+ | `v0/email-canonical.md` | Gmail 规范化(≥0.9.6) |
75
+ | `v0/breaking.md` | 0.x 内部 breaking |
76
+ | `references/tasks-integration-guide.md` | 只用 tasks 时读 |
215
77
 
216
- dwyeapi 把基础设施细节封装在自有 API 后面。业务代码**禁止**直接调下面这些底层 API,违反会导致升级困难、行为不一致:
78
+ ## 禁止
217
79
 
218
- | 禁止 | 改用 |
80
+ | 借口 | 实际 |
219
81
  |------|------|
220
- | `datetime.now()` / `datetime.utcnow()` | `dt.now()` / `dt.utc_now()` |
221
- | `import logging; logging.getLogger()` | `from dwyeapi import logger; logger.get_logger()` |
222
- | 手写 `from redis.asyncio import Redis` 全局变量 | `cache.configure()` + `cache.get_redis()` |
223
- | 手写 JWT encode/decode(含 `import jwt`) | `security.create_token()` / `security.decode_token()` |
224
- | 直接 `raise HTTPException(404, ...)` | `raise NotFoundError("资源名")` |
225
- | 自己写 `{"code": ..., "data": ...}` dict 当响应 | `ApiResponse.ok(data)` / `ApiResponse.page(...)` |
226
- | 自己写 `?page=&page_size=` Query 参数 | `params: PaginationParams = Depends()` |
82
+ | 读旁边 dwy-shared master | 消费方版本可能不同。先 clone 安装版本的 tag,不行再读本项目 venv |
83
+ | 文档是 0.9,装的 0.9.6,差不多 | 有下限的能力必须对照版本 |
84
+ | tag 没有就猜 / 直接停 | clone 失败后读 venv;venv 也没有才停 |
85
+ | 再开一个 `dwy-eapi-v0` skill | 对外只有 `dwy-eapi` |
@@ -0,0 +1,13 @@
1
+ # 防腐层
2
+
3
+ 业务代码禁止直调底层 API:
4
+
5
+ | 禁止 | 改用 |
6
+ |------|------|
7
+ | `datetime.now()` / `datetime.utcnow()` | `dt.now()` / `dt.utc_now()` |
8
+ | `import logging; logging.getLogger()` | `from dwyeapi import logger; logger.get_logger()` |
9
+ | 手写 `from redis.asyncio import Redis` 全局变量 | `cache.configure()` + `cache.get_redis()` |
10
+ | 手写 JWT encode/decode(含 `import jwt`) | `security.create_token()` / `security.decode_token()` |
11
+ | 直接 `raise HTTPException(404, ...)` | `raise NotFoundError("资源名")` |
12
+ | 自己写 `{"code": ..., "data": ...}` dict | `ApiResponse.ok(data)` / `ApiResponse.page(...)` |
13
+ | 自己写 `?page=&page_size=` Query | `params: PaginationParams = Depends()` |
@@ -0,0 +1,5 @@
1
+ # 分层
2
+
3
+ - **service 层**抛业务异常(`NotFoundError("用户")` / `BusinessError("余额不足", code="INSUFFICIENT_BALANCE")`),不感知 HTTP。
4
+ - **router 层**不写 try/except,由 `register_exception_handlers(app)` 统一转成 `ApiResponse` 信封。
5
+ - **禁止**在 service 层抛 `HTTPException` —— 会绕过统一 handler。
@@ -0,0 +1,20 @@
1
+ # 响应信封
2
+
3
+ 所有接口返回:
4
+
5
+ ```json
6
+ {
7
+ "code": "SUCCESS",
8
+ "message": "success",
9
+ "data": {},
10
+ "timestamp": 1775625000
11
+ }
12
+ ```
13
+
14
+ - 成功:`code = "SUCCESS"`,`data` 是业务载荷(单体或 `PageData[T]`)
15
+ - 失败:`code` 是业务错误码(`NOT_FOUND` / `BUSINESS_ERROR` / `VALIDATION_ERROR`),HTTP 状态保持语义
16
+ - 校验错误 `VALIDATION_ERROR`:`data.errors: [{field, message}]`,field 含 `body.` / `query.` / `path.` 前缀
17
+
18
+ 构造:`ApiResponse.ok(data)` 或 `ApiResponse.page(items, total, page, page_size)`,不直接实例化成功态。
19
+
20
+ 错误响应不要业务代码拼信封 —— `raise NotFoundError("用户")`,handler 自动转。
@@ -0,0 +1,7 @@
1
+ # 时间
2
+
3
+ 业务时间统一中国时区。**禁止 `datetime.now()` / `datetime.utcnow()`**,走 `dwyeapi.dt`。
4
+
5
+ - `dt.now()`:naive(Asia/Shanghai),数据库存储
6
+ - `dt.utc_now()`:aware UTC,JWT exp 等协议字段
7
+ - 两者不能混用
@@ -0,0 +1,12 @@
1
+ # 0.x 内部 breaking
2
+
3
+ 0.x 的 minor 也可以不兼容。整条线仍用 `v0/`,破坏点记在这里。细节 clone `dwyeapi@x.y.z`,不要另开 `v0.10/` 目录。
4
+
5
+ 完整段落以该 tag 的 `backend/CHANGELOG.md` 为准。
6
+
7
+ | 版本 | 破坏点 |
8
+ |------|--------|
9
+ | 0.6.0 | 信封改为 `ApiResponse[T]` / `PageData[T]`;删除 `success` / `fail` / `paginated`;`code` 成功态从 int `200` 改为 `"SUCCESS"` |
10
+ | 0.9.0 | 删除 `MockEmailProvider`、`AliyunEmailProvider`、整个 `providers.sms`;`EMAIL__PROVIDER` 改为自由字符串;mock/阿里云改业务 `register_email_provider` |
11
+
12
+ 0.9.6 起新增 `dwyeapi.email` 与 Redis key 规范化,对旧调用方兼容(发码 key 折到同一收件箱)。能力下限见 `email-canonical.md`。
@@ -0,0 +1,18 @@
1
+ # 邮箱规范化(≥0.9.6)
2
+
3
+ 安装版本 `< 0.9.6`:没有 `dwyeapi.email`,不要 import。
4
+
5
+ ```python
6
+ from dwyeapi.email import canonicalize_email, is_folded_alias, GMAIL_DOMAINS
7
+
8
+ canonicalize_email("Bai.Wen+1@Gmail.com") # baiwen@gmail.com
9
+ canonicalize_email("a.b@googlemail.com") # ab@gmail.com
10
+ canonicalize_email("a+x@icloud.com") # a+x@icloud.com
11
+ is_folded_alias("a+x@gmail.com") # True
12
+ is_folded_alias("A@gmail.com") # False
13
+ ```
14
+
15
+ - 注册查重、登录查找、用户表写入 **必须** 走 `canonicalize_email`
16
+ - Gmail 把 plus / 点号 / `googlemail.com` 当成同一收件箱,不折会开多个号、绕过发码冷却
17
+ - 其它域名只做 strip + lower,保留 `+` 与点
18
+ - `send_code` / `verify_code` 的 Redis key 已自动规范化;业务存库仍要自己调