@adep/cli 0.2.0 → 0.2.2

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 (3) hide show
  1. package/dist/index.js +128 -51
  2. package/dist/vite.js +72 -13
  3. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -596,12 +596,12 @@ var init_generated = __esm({
596
596
  "viteConfig",
597
597
  "gitignore"
598
598
  ],
599
- "source": "apps/countdown\uFF08functions/ / web/src/ / database/ \u7531 scripts/build-templates.mjs \u540C\u6B65\uFF09"
599
+ "source": "\u672C\u76EE\u5F55\u5373\u771F\u76F8\u6E90\uFF08\u539F apps/countdown \u5DF2\u79FB\u51FA\u4ED3\u5E93\uFF0C\u72EC\u7ACB\u7EF4\u62A4\u4E8E git.a.jajabjbj.top:6602/a/countdown-adep.git\uFF09"
600
600
  },
601
601
  "files": [
602
602
  {
603
603
  "path": "README.md",
604
- "content": "# {{name}}\n\n\u5012\u6570\u65E5 / \u7EAA\u5FF5\u65E5\uFF08countdown \u6A21\u677F\uFF09\u2014\u2014\u7531\u5FAE\u4FE1\u5C0F\u7A0B\u5E8F\u300C\u5012\u6570\u65E5\u300D\u79FB\u690D\u7684 adep \u5168\u6808\u5E94\u7528\uFF1A\n`functions/` \u4E91\u51FD\u6570\uFF08auth / events + `_shared/` \u5171\u4EAB\u6A21\u5757\uFF09+ `web/src/` \u524D\u7AEF\u6E90\u7801\uFF08Vue 3 + Vite\uFF0C\nHome / Edit / Profile \u4E09\u9875 + OIDC \u56DE\u8C03\u9875\u3001\u81EA\u5B9A\u4E49 TabBar + \u5DE6\u6ED1\u5220\u9664 / \u957F\u6309\u7F6E\u9876 / \u4E0B\u62C9\u5237\u65B0\uFF0C\n\u754C\u9762\u4E0E\u5C0F\u7A0B\u5E8F\u5BF9\u9F50\uFF09\u3002vite \u5DE5\u7A0B\u6839\u5728**\u9879\u76EE\u6839**\uFF08`index.html` + `vite.config.ts` + \u6839 `package.json`\uFF09\u3002\n\n> \u672C\u6A21\u677F\u7684 `functions/ / web/src/ / database/` \u7531 `scripts/build-templates.mjs` \u4ECE\n> `apps/countdown` \u540C\u6B65\uFF08\u6539\u5E94\u7528\u540E\u91CD\u8DD1\u811A\u672C\u66F4\u65B0\u6A21\u677F\uFF0C\u52FF\u624B\u6539\u8FD9\u4E9B\u76EE\u5F55\uFF09\u3002\n\n## \u5FEB\u901F\u5F00\u59CB\n\n```bash\nnpm install # \u5355\u4E00\u5DE5\u7A0B\u6839\uFF1A@adep/cli + \u524D\u7AEF\u4F9D\u8D56\uFF08vue / vue-router / @adep/auth-client\uFF09+ \u6784\u5EFA\u94FE\nnpm run dev # \u5168\u6808\u5F00\u53D1\uFF1Avite \u8FDB\u7A0B\u5185\u542F\u52A8 adep dev + /api \u4EE3\u7406\uFF0C\u524D\u7AEF HMR + \u51FD\u6570\u70ED\u91CD\u8F7D\nnpm test # \u51FD\u6570/\u5DE5\u5177\u5171\u4EAB\u903B\u8F91\u5355\u5143\u6D4B\u8BD5\uFF08vitest\uFF09\nnpm run build # \u6784\u5EFA\u524D\u7AEF \u2192 site/\nnpm run start # \u72EC\u7ACB\u8FD0\u884C\uFF08\u672C\u5730\u6A21\u62DF\u8FD0\u884C\u65F6 + \u9759\u6001\u6258\u7BA1 + --schema \u81EA\u52A8\u5EFA\u8868\uFF09\uFF0C\u6D4F\u89C8\u5668\u5F00 http://127.0.0.1:8787\n```\n\n## \u90E8\u7F72\u5230 adep \u5E73\u53F0\n\n```bash\nadep login # \u767B\u5F55\uFF08\u5199 ~/.adep/credentials\uFF09\nnpm run deploy # \u53D1\u5E03\u4E91\u51FD\u6570\uFF08\u542B _shared/ \u5171\u4EAB\u6E90\u7801\uFF09\nnpm run build && npm run site:deploy # \u6784\u5EFA\u524D\u7AEF \u2192 \u4E0A\u4F20 site/ \u5230\u5E73\u53F0\u9759\u6001\u6258\u7BA1\uFF08\u5F00 SPA \u56DE\u9000\uFF09\n```\n\n\u53D1\u5E03\u540E\u8BBF\u95EE `https://{project-slug}.adep.localhost:3001`\uFF08\u672C\u5730 dev \u5E73\u53F0\uFF09\u6216\u7EBF\u4E0A\u7AD9\u70B9\u5730\u5740\u3002\n\n> \u524D\u7AEF\u4E3A\u4EC0\u4E48\u4E0D\u7528 `adep publish`\uFF08\u5168\u6808\uFF09\uFF1F\u90A3\u6761\u8DEF\u8D70\u5E73\u53F0\u300C\u524D\u7AEF\u8349\u7A3F\u6784\u5EFA\u300D\u7BA1\u7EBF\uFF0C\u800C\u5B83\u7684\u5165\u53E3\u95E8\u69DB\u662F\n> \u8349\u7A3F\u91CC\u5B58\u5728 `web/index.html`\uFF1B\u672C\u6A21\u677F\u7684\u89C4\u8303\u5F62\u6001\u662F\u300C\u6839 vite \u5DE5\u7A0B + `web/` \u53EA\u653E\u6E90\u7801\u300D\uFF0C\u8349\u7A3F\u91CC\n> \u6CA1\u6709\u8BE5\u6587\u4EF6\u3002\u524D\u7AEF\u4E0A\u7EBF\u7EDF\u4E00\u8D70 `vite build` \u2192 `site/` \u2192 `adep hosting deploy site --spa`\n> \uFF08\u4E0E fullstack \u6A21\u677F\u540C\u4E00\u53E3\u5F84\uFF09\u3002\n\n## \u72EC\u7ACB\u90E8\u7F72\uFF08\u4E0D\u4F9D\u8D56 adep \u5E73\u53F0\uFF09\n\n`npm run build && npm run start` \u5373\u53EF\u672C\u5730\u72EC\u7ACB\u8DD1\uFF08adep serve \u4E0E\u5E73\u53F0\u540C\u6E90\u6267\u884C\u5668\uFF09\u3002\n\u5BB9\u5668\u5316\u90E8\u7F72\u53C2\u8003 `apps/countdown` \u7684 `Dockerfile` + `compose.yaml`\uFF08\u5355\u5BB9\u5668 =\nserve \u627F\u8F7D /api/* + \u9759\u6001\u6258\u7BA1 site/ + .adep/sim/ \u6570\u636E\u5377\uFF09\u3002\n\n## \u76EE\u5F55\u7ED3\u6784\n\n- `adep.config.ts`\uFF1A\u9879\u76EE\u914D\u7F6E\uFF08name / template / functionsDir / functions_prefix \u9ED8\u8BA4 /api\uFF09\n- `index.html` / `vite.config.ts`\uFF1Avite \u5165\u53E3\u4E0E\u914D\u7F6E\uFF08\u5DE5\u7A0B\u6839 = \u9879\u76EE\u6839\uFF0C`adep()` \u63D2\u4EF6\u4EE3\u7406 /api/*\uFF0Cbuild \u8F93\u51FA `site/`\uFF09\n- `functions/`\uFF1A\u4E91\u51FD\u6570\u76EE\u5F55\u2014\u2014`auth.ts`\uFF08\u767B\u5F55/\u4F1A\u8BDD\uFF1AOIDC \u8EAB\u4EFD\u4F18\u5148\u3001device \u8EAB\u4EFD\u517C\u5BB9\uFF09\u3001`events.ts`\n \uFF08\u4E8B\u4EF6 CRUD + \u7F6E\u9876\u6392\u5E8F\uFF09\u3001`_shared/`\uFF08auth / store / date / response / route \u5171\u4EAB\u6A21\u5757\n + \u5BF9\u5E94 vitest \u5355\u6D4B\uFF0C\u5168\u9879\u76EE\u5355\u4EFD\uFF09\n- `database/schema.sql`\uFF1A\u6570\u636E\u5E93\u7ED3\u6784\uFF08users / events \u8868\uFF1B`adep db migrate database/schema.sql` \u5E94\u7528\uFF09\n- `web/src/`\uFF1A\u524D\u7AEF\u6E90\u7801\u2014\u2014`web/src/views/`\uFF08Home / Edit / Profile + `OidcCallbackView.vue`\uFF09\u3001\n `web/src/ui/`\uFF08TabBar / UiHost \u590D\u523B wx.showToast \u7B49\uFF09\u3001`web/src/lib/`\n \uFF08request / auth / config / date / events / oidc\uFF09\n- `web/src/lib/request.ts`\uFF1Afetch \u5C01\u88C5\uFF08\u4E1A\u52A1\u4FE1\u5C01 + 40001 \u81EA\u52A8\u5237\u65B0\u91CD\u8BD5\uFF09\n- `site/`\uFF1A`npm run build` \u7684\u524D\u7AEF\u6784\u5EFA\u4EA7\u7269\uFF08\u7AD9\u70B9\u6258\u7BA1\u76EE\u5F55\uFF0C\u5DF2 gitignore\uFF09\n- `tsconfig.json` / `adep.d.ts`\uFF1ATypeScript \u914D\u7F6E\u4E0E\u4E91\u51FD\u6570\u4E0A\u4E0B\u6587\u7C7B\u578B\u58F0\u660E\n\n## \u7528\u6237\u4F53\u7CFB\u8BF4\u660E\uFF08\u767B\u5F55\u94FE\u8DEF\uFF09\n\n- **OIDC \u767B\u5F55\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u4E3B\u94FE\u8DEF\uFF09**\uFF1A`web/src/lib/oidc.ts` \u63A5\u5165\u5E73\u53F0\u4E09\u65B9\u767B\u5F55\uFF08\u6388\u6743\u7801 +\n PKCE S256\uFF0C\u4F1A\u8BDD\u81EA\u7BA1 localStorage\uFF0Caccess token \u8FC7\u671F\u81EA\u52A8\u5237\u65B0\uFF09\uFF0C`OidcCallbackView.vue`\n \u5904\u7406\u6388\u6743\u56DE\u8C03\uFF1B`functions/_shared/auth.ts` \u628A\u7F51\u5173\u89E3\u6790\u7684 `ctx.user`\uFF08\u5E73\u53F0\u7528\u6237\uFF09\u4EE5\n `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u5173\u8054\u672C\u5730 users \u8868\uFF08\u96F6\u8FC1\u79FB\uFF0C\u7F3A\u884C\u5373\u5EFA\uFF09\u3002\u672A\u767B\u5F55\u65F6\u6570\u636E\u5B58 localStorage\n \uFF08`lib/events.ts`\uFF09\uFF0C\u767B\u5F55\u540E\u540C\u6B65\u4E91\u7AEF\u3002\n- **\u8BBE\u5907\u8EAB\u4EFD\uFF08\u517C\u5BB9\u65E7\u5BA2\u6237\u7AEF / API \u8C03\u7528\uFF0C\u515C\u5E95\uFF09**\uFF1A`POST /auth/login` \u6362\u53D6 device_id \u4F5C\n Bearer \u51ED\u636E\uFF08\u7B49\u4EF7 API Key\uFF09\uFF1Bweb \u524D\u7AEF\u5DF2\u8FC1\u79FB OIDC\uFF0C\u4E0D\u518D\u8D70\u8BBE\u5907\u767B\u5F55\u3002\u672A\u767B\u5F55/\u5931\u6548\u8FD4\u56DE\n \u4E1A\u52A1\u7801 40001\uFF0C`request.ts` \u636E\u6B64\u6E05\u4F1A\u8BDD\u3001\u6709 OIDC \u4F1A\u8BDD\u5219\u5F3A\u5236\u5237\u65B0\u91CD\u8BD5\u4E00\u6B21\u3002\n\n## \u66F4\u591A\n\n- \u79FB\u690D\u7ECF\u9A8C\u4E0E\u9010\u9879\u5BF9\u9F50\u8BF4\u660E\uFF1A`packages/docs/tutorial-countdown.md`\uFF08adep \u4ED3\u5E93\u6587\u6863\uFF09\n- \u72EC\u7ACB\u90E8\u7F72\u5BB9\u5668\u914D\u7F6E\u89C1 adep \u4ED3\u5E93 `apps/countdown`\uFF08Dockerfile / compose.yaml\uFF09\n"
604
+ "content": "# {{name}}\n\n\u5012\u6570\u65E5 / \u7EAA\u5FF5\u65E5\uFF08countdown \u6A21\u677F\uFF09\u2014\u2014\u7531\u5FAE\u4FE1\u5C0F\u7A0B\u5E8F\u300C\u5012\u6570\u65E5\u300D\u79FB\u690D\u7684 adep \u5168\u6808\u5E94\u7528\uFF1A\n`functions/` \u4E91\u51FD\u6570\uFF08auth / events + `_shared/` \u5171\u4EAB\u6A21\u5757\uFF09+ `web/src/` \u524D\u7AEF\u6E90\u7801\uFF08Vue 3 + Vite\uFF0C\nHome / Edit / Profile \u4E09\u9875 + OIDC \u56DE\u8C03\u9875\u3001\u81EA\u5B9A\u4E49 TabBar + \u5DE6\u6ED1\u5220\u9664 / \u957F\u6309\u7F6E\u9876 / \u4E0B\u62C9\u5237\u65B0\uFF0C\n\u754C\u9762\u4E0E\u5C0F\u7A0B\u5E8F\u5BF9\u9F50\uFF09\u3002vite \u5DE5\u7A0B\u6839\u5728**\u9879\u76EE\u6839**\uFF08`index.html` + `vite.config.ts` + \u6839 `package.json`\uFF09\u3002\n\n> \u672C\u6A21\u677F\u76EE\u5F55\u662F\u81EA\u5305\u542B\u771F\u76F8\u6E90\uFF08`scripts/build-templates.mjs` \u7531\u6B64\u751F\u6210 `generated.ts` \u5FEB\u7167\uFF0C\n> \u6539\u6A21\u677F\u540E\u91CD\u8DD1\u811A\u672C\uFF09\u3002**\u5B8C\u6574\u7248**\u5012\u6570\u65E5\uFF089 \u51FD\u6570\u57DF + \u4F1A\u5458/\u88C2\u53D8/\u519C\u5386/\u63D0\u9192 + 102 \u9879\u6D4B\u8BD5\uFF09\u89C1\u72EC\u7ACB\u4ED3\u5E93 [a/countdown-adep](https://git.a.jajabjbj.top:6602/a/countdown-adep)\uFF0C\u53EF\u88AB\u5E73\u53F0\u76F4\u63A5 from-git \u5BFC\u5165\u3002\n\n## \u5FEB\u901F\u5F00\u59CB\n\n```bash\nnpm install # \u5355\u4E00\u5DE5\u7A0B\u6839\uFF1A@adep/cli + \u524D\u7AEF\u4F9D\u8D56\uFF08vue / vue-router / @adep/auth-client\uFF09+ \u6784\u5EFA\u94FE\nnpm run dev # \u5168\u6808\u5F00\u53D1\uFF1Avite \u8FDB\u7A0B\u5185\u542F\u52A8 adep dev + /api \u4EE3\u7406\uFF0C\u524D\u7AEF HMR + \u51FD\u6570\u70ED\u91CD\u8F7D\nnpm test # \u51FD\u6570/\u5DE5\u5177\u5171\u4EAB\u903B\u8F91\u5355\u5143\u6D4B\u8BD5\uFF08vitest\uFF09\nnpm run build # \u6784\u5EFA\u524D\u7AEF \u2192 site/\nnpm run start # \u72EC\u7ACB\u8FD0\u884C\uFF08\u672C\u5730\u6A21\u62DF\u8FD0\u884C\u65F6 + \u9759\u6001\u6258\u7BA1 + --schema \u81EA\u52A8\u5EFA\u8868\uFF09\uFF0C\u6D4F\u89C8\u5668\u5F00 http://127.0.0.1:8787\n```\n\n## \u90E8\u7F72\u5230 adep \u5E73\u53F0\n\n```bash\nadep login # \u767B\u5F55\uFF08\u5199 ~/.adep/credentials\uFF09\nnpm run deploy # \u53D1\u5E03\u4E91\u51FD\u6570\uFF08\u542B _shared/ \u5171\u4EAB\u6E90\u7801\uFF09\nnpm run build && npm run site:deploy # \u6784\u5EFA\u524D\u7AEF \u2192 \u4E0A\u4F20 site/ \u5230\u5E73\u53F0\u9759\u6001\u6258\u7BA1\uFF08\u5F00 SPA \u56DE\u9000\uFF09\n```\n\n\u53D1\u5E03\u540E\u8BBF\u95EE `https://{project-slug}.adep.localhost:3001`\uFF08\u672C\u5730 dev \u5E73\u53F0\uFF09\u6216\u7EBF\u4E0A\u7AD9\u70B9\u5730\u5740\u3002\n\n> \u524D\u7AEF\u4E3A\u4EC0\u4E48\u4E0D\u7528 `adep publish`\uFF08\u5168\u6808\uFF09\uFF1F\u90A3\u6761\u8DEF\u8D70\u5E73\u53F0\u300C\u524D\u7AEF\u8349\u7A3F\u6784\u5EFA\u300D\u7BA1\u7EBF\uFF0C\u800C\u5B83\u7684\u5165\u53E3\u95E8\u69DB\u662F\n> \u8349\u7A3F\u91CC\u5B58\u5728 `web/index.html`\uFF1B\u672C\u6A21\u677F\u7684\u89C4\u8303\u5F62\u6001\u662F\u300C\u6839 vite \u5DE5\u7A0B + `web/` \u53EA\u653E\u6E90\u7801\u300D\uFF0C\u8349\u7A3F\u91CC\n> \u6CA1\u6709\u8BE5\u6587\u4EF6\u3002\u524D\u7AEF\u4E0A\u7EBF\u7EDF\u4E00\u8D70 `vite build` \u2192 `site/` \u2192 `adep hosting deploy site --spa`\n> \uFF08\u4E0E fullstack \u6A21\u677F\u540C\u4E00\u53E3\u5F84\uFF09\u3002\n\n## \u72EC\u7ACB\u90E8\u7F72\uFF08\u4E0D\u4F9D\u8D56 adep \u5E73\u53F0\uFF09\n\n`npm run build && npm run start` \u5373\u53EF\u672C\u5730\u72EC\u7ACB\u8DD1\uFF08adep serve \u4E0E\u5E73\u53F0\u540C\u6E90\u6267\u884C\u5668\uFF09\u3002\n\u5BB9\u5668\u5316\u90E8\u7F72\u53C2\u8003\u72EC\u7ACB\u4ED3\u5E93 a/countdown-adep \u7684 `Dockerfile` + `compose.yaml`\uFF08\u5355\u5BB9\u5668 =\nserve \u627F\u8F7D /api/* + \u9759\u6001\u6258\u7BA1 site/ + .adep/sim/ \u6570\u636E\u5377\uFF09\u3002\n\n## \u76EE\u5F55\u7ED3\u6784\n\n- `adep.config.ts`\uFF1A\u9879\u76EE\u914D\u7F6E\uFF08name / template / functionsDir / functions_prefix \u9ED8\u8BA4 /api\uFF09\n- `index.html` / `vite.config.ts`\uFF1Avite \u5165\u53E3\u4E0E\u914D\u7F6E\uFF08\u5DE5\u7A0B\u6839 = \u9879\u76EE\u6839\uFF0C`adep()` \u63D2\u4EF6\u4EE3\u7406 /api/*\uFF0Cbuild \u8F93\u51FA `site/`\uFF09\n- `functions/`\uFF1A\u4E91\u51FD\u6570\u76EE\u5F55\u2014\u2014`auth.ts`\uFF08\u767B\u5F55/\u4F1A\u8BDD\uFF1AOIDC \u8EAB\u4EFD\u4F18\u5148\u3001device \u8EAB\u4EFD\u517C\u5BB9\uFF09\u3001`events.ts`\n \uFF08\u4E8B\u4EF6 CRUD + \u7F6E\u9876\u6392\u5E8F\uFF09\u3001`_shared/`\uFF08auth / store / date / response / route \u5171\u4EAB\u6A21\u5757\n + \u5BF9\u5E94 vitest \u5355\u6D4B\uFF0C\u5168\u9879\u76EE\u5355\u4EFD\uFF09\n- `database/schema.sql`\uFF1A\u6570\u636E\u5E93\u7ED3\u6784\uFF08users / events \u8868\uFF1B`adep db migrate database/schema.sql` \u5E94\u7528\uFF09\n- `web/src/`\uFF1A\u524D\u7AEF\u6E90\u7801\u2014\u2014`web/src/views/`\uFF08Home / Edit / Profile + `OidcCallbackView.vue`\uFF09\u3001\n `web/src/ui/`\uFF08TabBar / UiHost \u590D\u523B wx.showToast \u7B49\uFF09\u3001`web/src/lib/`\n \uFF08request / auth / config / date / events / oidc\uFF09\n- `web/src/lib/request.ts`\uFF1Afetch \u5C01\u88C5\uFF08\u4E1A\u52A1\u4FE1\u5C01 + 40001 \u81EA\u52A8\u5237\u65B0\u91CD\u8BD5\uFF09\n- `site/`\uFF1A`npm run build` \u7684\u524D\u7AEF\u6784\u5EFA\u4EA7\u7269\uFF08\u7AD9\u70B9\u6258\u7BA1\u76EE\u5F55\uFF0C\u5DF2 gitignore\uFF09\n- `tsconfig.json` / `adep.d.ts`\uFF1ATypeScript \u914D\u7F6E\u4E0E\u4E91\u51FD\u6570\u4E0A\u4E0B\u6587\u7C7B\u578B\u58F0\u660E\n\n## \u7528\u6237\u4F53\u7CFB\u8BF4\u660E\uFF08\u767B\u5F55\u94FE\u8DEF\uFF09\n\n- **OIDC \u767B\u5F55\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u4E3B\u94FE\u8DEF\uFF09**\uFF1A`web/src/lib/oidc.ts` \u63A5\u5165\u5E73\u53F0\u4E09\u65B9\u767B\u5F55\uFF08\u6388\u6743\u7801 +\n PKCE S256\uFF0C\u4F1A\u8BDD\u81EA\u7BA1 localStorage\uFF0Caccess token \u8FC7\u671F\u81EA\u52A8\u5237\u65B0\uFF09\uFF0C`OidcCallbackView.vue`\n \u5904\u7406\u6388\u6743\u56DE\u8C03\uFF1B`functions/_shared/auth.ts` \u628A\u7F51\u5173\u89E3\u6790\u7684 `ctx.user`\uFF08\u5E73\u53F0\u7528\u6237\uFF09\u4EE5\n `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u5173\u8054\u672C\u5730 users \u8868\uFF08\u96F6\u8FC1\u79FB\uFF0C\u7F3A\u884C\u5373\u5EFA\uFF09\u3002\u672A\u767B\u5F55\u65F6\u6570\u636E\u5B58 localStorage\n \uFF08`lib/events.ts`\uFF09\uFF0C\u767B\u5F55\u540E\u540C\u6B65\u4E91\u7AEF\u3002\n- **\u8BBE\u5907\u8EAB\u4EFD\uFF08\u517C\u5BB9\u65E7\u5BA2\u6237\u7AEF / API \u8C03\u7528\uFF0C\u515C\u5E95\uFF09**\uFF1A`POST /auth/login` \u6362\u53D6 device_id \u4F5C\n Bearer \u51ED\u636E\uFF08\u7B49\u4EF7 API Key\uFF09\uFF1Bweb \u524D\u7AEF\u5DF2\u8FC1\u79FB OIDC\uFF0C\u4E0D\u518D\u8D70\u8BBE\u5907\u767B\u5F55\u3002\u672A\u767B\u5F55/\u5931\u6548\u8FD4\u56DE\n \u4E1A\u52A1\u7801 40001\uFF0C`request.ts` \u636E\u6B64\u6E05\u4F1A\u8BDD\u3001\u6709 OIDC \u4F1A\u8BDD\u5219\u5F3A\u5236\u5237\u65B0\u91CD\u8BD5\u4E00\u6B21\u3002\n\n## \u66F4\u591A\n\n- \u79FB\u690D\u7ECF\u9A8C\u4E0E\u9010\u9879\u5BF9\u9F50\u8BF4\u660E\uFF1Aadep \u6587\u6863\u7AD9\u300C\u5B9E\u6218\uFF1A\u5FAE\u4FE1\u5C0F\u7A0B\u5E8F\u79FB\u690D\u5012\u6570\u65E5\u300D\uFF08tutorial-countdown.md\uFF09\n- \u72EC\u7ACB\u90E8\u7F72\u5BB9\u5668\u914D\u7F6E\u89C1\u72EC\u7ACB\u4ED3\u5E93 a/countdown-adep\uFF08Dockerfile / compose.yaml\uFF09\n"
605
605
  },
606
606
  {
607
607
  "path": "database/schema.sql",
@@ -609,39 +609,31 @@ var init_generated = __esm({
609
609
  },
610
610
  {
611
611
  "path": "functions/_shared/auth.test.ts",
612
- "content": "/**\n * \u8BF7\u6C42\u9274\u6743\u5355\u5143\u6D4B\u8BD5\uFF1AOIDC \u8EAB\u4EFD\uFF08ctx.user \u2192 `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u6620\u5C04\uFF09\u4E0E\n * device_id \u53CC\u6765\u6E90\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u89C1 _shared/auth.ts\uFF09\u3002\n * \u8FD0\u884C\uFF1Anpm run test\n */\nimport { describe, it, expect } from 'vitest'\nimport type { CloudDb, FunctionContext } from '@adep/types'\nimport {\n parseBearer,\n requireUser,\n isEnvelope,\n OIDC_DEVICE_PREFIX,\n deviceIdOfOidcUser,\n} from './auth'\nimport { findUserByDeviceId } from './store'\nimport type { UserRow } from './store'\nimport type { AdepHttpEnvelopeLike } from './auth'\n\n/** \u5185\u5B58 CloudDb \u6869\uFF1A\u53EA\u5B9E\u73B0 store.ts / requireUser \u7528\u5230\u7684\u64CD\u4F5C\uFF08users \u8868 where+first/insert\uFF09\u3002 */\nfunction makeMemoryDb(seed: UserRow[] = []): { db: CloudDb; users: UserRow[] } {\n const users: UserRow[] = [...seed]\n const db = {\n table(name: string) {\n let filters: Array<[string, unknown]> = []\n const chain = {\n select(): unknown {\n return chain\n },\n owned(): unknown {\n return chain\n },\n where(col: string, value: unknown): unknown {\n filters = [...filters, [col, value]]\n return chain\n },\n async first(): Promise<Record<string, unknown> | null> {\n const hit = users.find((u) => filters.every(([col, v]) => (u as unknown as Record<string, unknown>)[col] === v))\n return hit === undefined ? null : { ...hit }\n },\n async insert(row: Record<string, unknown>): Promise<void> {\n users.push(row as unknown as UserRow)\n },\n }\n void name\n return chain\n },\n } as unknown as CloudDb\n return { db, users }\n}\n\n/** \u7EC4\u88C5\u6700\u5C0F FunctionContext\uFF08cloud \u5E26 db\uFF0C\u5176\u4F59\u5B57\u6BB5\u6309\u7F3A\u7701\uFF09\u3002 */\nfunction ctxOf(db: CloudDb, overrides: Partial<FunctionContext> = {}): FunctionContext {\n return {\n method: 'GET',\n path: '/auth/me',\n query: {},\n headers: {},\n body: undefined,\n files: [],\n cloud: { db } as FunctionContext['cloud'],\n user: null,\n ...overrides,\n }\n}\n\nfunction envelopeBody(r: { user: UserRow } | AdepHttpEnvelopeLike): {\n status: number\n body?: unknown\n} {\n if (isEnvelope(r)) return (r as AdepHttpEnvelopeLike).__adepHttp\n throw new Error('expected envelope')\n}\n\ndescribe('deviceIdOfOidcUser', () => {\n it('\u4EE5 `oidc:` \u524D\u7F00 + \u5E73\u53F0\u7528\u6237 id \u7EC4\u6210\u672C\u5730 device_id', () => {\n expect(deviceIdOfOidcUser({ id: 'plat-123' })).toBe(`${OIDC_DEVICE_PREFIX}plat-123`)\n })\n})\n\ndescribe('requireUser \xB7 OIDC \u8EAB\u4EFD\uFF08ctx.user\uFF09', () => {\n it('\u9996\u6B21\u8BBF\u95EE\u81EA\u52A8\u5EFA\u884C\uFF08\u6620\u5C04\u5230 oidc:<\u5E73\u53F0\u7528\u6237 id>\uFF0C\u6635\u79F0\u53D6 ctx.user.name\uFF09', async () => {\n const { db, users } = makeMemoryDb()\n const ctx = ctxOf(db, { user: { id: 'plat-1', name: '\u5C0F\u660E', email: 'm@x.com' } })\n\n const r = await requireUser(db, ctx)\n expect(isEnvelope(r)).toBe(false)\n if (isEnvelope(r)) return\n expect(r.user.device_id).toBe('oidc:plat-1')\n expect(r.user.nickname).toBe('\u5C0F\u660E')\n expect(users).toHaveLength(1)\n\n // \u5DF2\u5EFA\u884C\uFF1A\u518D\u6B21\u8BBF\u95EE\u590D\u7528\u540C\u4E00\u884C\uFF0C\u4E0D\u91CD\u590D\u5EFA\n const again = await requireUser(db, ctx)\n expect(isEnvelope(again)).toBe(false)\n if (isEnvelope(again)) return\n expect(again.user.id).toBe(r.user.id)\n expect(users).toHaveLength(1)\n })\n\n it('\u7F3A name \u65F6\u6635\u79F0\u56DE\u9000\u300C\u5E73\u53F0\u7528\u6237\u300D', async () => {\n const { db, users } = makeMemoryDb()\n const r = await requireUser(db, ctxOf(db, { user: { id: 'plat-2' } }))\n expect(isEnvelope(r)).toBe(false)\n if (isEnvelope(r)) return\n expect(r.user.nickname).toBe('\u5E73\u53F0\u7528\u6237')\n expect(users).toHaveLength(1)\n })\n\n it('\u5DF2\u5B58\u5728\u7684 OIDC \u6620\u5C04\u884C\u76F4\u63A5\u547D\u4E2D\uFF08\u4E0D\u67E5 device Bearer\uFF09', async () => {\n const existing: UserRow = {\n id: 'local-9',\n device_id: 'oidc:plat-9',\n nickname: '\u8001\u7528\u6237',\n avatar_url: '',\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n }\n const { db, users } = makeMemoryDb([existing])\n const r = await requireUser(db, ctxOf(db, { user: { id: 'plat-9' } }))\n expect(isEnvelope(r)).toBe(false)\n if (isEnvelope(r)) return\n expect(r.user.id).toBe('local-9')\n expect(users).toHaveLength(1)\n })\n})\n\ndescribe('requireUser \xB7 \u8BBE\u5907\u8EAB\u4EFD\uFF08Bearer device_id\uFF09', () => {\n it('\u5408\u6CD5 device_id \u547D\u4E2D\u7528\u6237', async () => {\n const row: UserRow = {\n id: 'local-1',\n device_id: 'dev-abc-123',\n nickname: '\u672C\u5730\u7528\u6237',\n avatar_url: '',\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n }\n const { db } = makeMemoryDb([row])\n const ctx = ctxOf(db, {\n headers: { authorization: 'Bearer dev-abc-123' },\n user: null,\n })\n const r = await requireUser(db, ctx)\n expect(isEnvelope(r)).toBe(false)\n if (isEnvelope(r)) return\n expect(r.user.id).toBe('local-1')\n })\n\n it('\u65E0\u51ED\u636E \u2192 40001 \u4FE1\u5C01', async () => {\n const { db } = makeMemoryDb()\n const r = await requireUser(db, ctxOf(db))\n expect(isEnvelope(r)).toBe(true)\n const e = envelopeBody(r)\n expect(e.status).toBe(401)\n })\n\n it('token \u67E5\u65E0\u7528\u6237 \u2192 40001 \u4FE1\u5C01', async () => {\n const { db } = makeMemoryDb()\n const r = await requireUser(db, ctxOf(db, { headers: { authorization: 'Bearer ghost' } }))\n expect(isEnvelope(r)).toBe(true)\n const e = envelopeBody(r)\n expect(e.status).toBe(401)\n })\n})\n\ndescribe('parseBearer', () => {\n it('\u5927\u5C0F\u5199 / \u591A\u4F59\u7A7A\u683C\u5BB9\u9519', () => {\n const mk = () => makeMemoryDb().db\n expect(parseBearer(ctxOf(mk(), { headers: { authorization: 'Bearer abc' } }))).toBe('abc')\n expect(parseBearer(ctxOf(mk(), { headers: { Authorization: ' bearer xyz ' } }))).toBe('xyz')\n expect(parseBearer(ctxOf(mk()))).toBe('')\n })\n})\n\ndescribe('store \xB7 OIDC \u6620\u5C04\u884C\u53EF\u88AB\u67E5\u8BE2', () => {\n it('findUserByDeviceId \u80FD\u627E\u5230 oidc: \u524D\u7F00\u884C\uFF08\u51FD\u6570\u5C42\u4E0E\u6570\u636E\u5C42\u4E00\u81F4\uFF09', async () => {\n const { db, users } = makeMemoryDb()\n const ctx = ctxOf(db, { user: { id: 'plat-7', name: '\u6D4B' } })\n const r = await requireUser(db, ctx)\n expect(isEnvelope(r)).toBe(false)\n if (isEnvelope(r)) return\n const found = await findUserByDeviceId(db, r.user.device_id)\n expect(found?.id).toBe(r.user.id)\n expect(users.some((u) => u.device_id.startsWith('oidc:'))).toBe(true)\n })\n})\n"
612
+ "content": "/**\n * \u8BF7\u6C42\u9274\u6743\u5355\u5143\u6D4B\u8BD5\uFF1AOIDC \u8EAB\u4EFD\uFF08ctx.user \u2192 `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u6620\u5C04\uFF09\u4E0E\n * device_id \u53CC\u6765\u6E90\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u89C1 _shared/auth.ts\uFF09\uFF1BFN-029 \u8D77\u8986\u76D6 authGuard\n * \u4E2D\u95F4\u4EF6\u5F62\u6001\uFF08state \u80CC\u5305 + 401 \u77ED\u8DEF\u4FE1\u5C01\uFF09\u3002\n * \u8FD0\u884C\uFF1Anpm run test\n */\nimport { describe, it, expect } from 'vitest'\nimport type { CloudDb, FunctionContext } from '@adep/types'\nimport { isAdepHttpEnvelope } from '@adep/runtime/shared/http-envelope'\nimport {\n parseBearer,\n resolveUser,\n authGuard,\n OIDC_DEVICE_PREFIX,\n deviceIdOfOidcUser,\n} from './auth'\nimport { findUserByDeviceId } from './store'\nimport type { UserRow } from './store'\n\n/** \u5185\u5B58 CloudDb \u6869\uFF1A\u53EA\u5B9E\u73B0 store.ts / resolveUser \u7528\u5230\u7684\u64CD\u4F5C\uFF08users \u8868 where+first/insert\uFF09\u3002 */\nfunction makeMemoryDb(seed: UserRow[] = []): { db: CloudDb; users: UserRow[] } {\n const users: UserRow[] = [...seed]\n const db = {\n table(name: string) {\n let filters: Array<[string, unknown]> = []\n const chain = {\n select(): unknown {\n return chain\n },\n owned(): unknown {\n return chain\n },\n where(col: string, value: unknown): unknown {\n filters = [...filters, [col, value]]\n return chain\n },\n async first(): Promise<Record<string, unknown> | null> {\n const hit = users.find((u) =>\n filters.every(([col, v]) => (u as unknown as Record<string, unknown>)[col] === v)\n )\n return hit === undefined ? null : { ...hit }\n },\n async insert(row: Record<string, unknown>): Promise<void> {\n users.push(row as unknown as UserRow)\n },\n }\n void name\n return chain\n },\n } as unknown as CloudDb\n return { db, users }\n}\n\n/** \u7EC4\u88C5\u6700\u5C0F FunctionContext\uFF08cloud \u5E26 db\uFF0C\u5176\u4F59\u5B57\u6BB5\u6309\u7F3A\u7701\uFF09\u3002 */\nfunction ctxOf(db: CloudDb, overrides: Partial<FunctionContext> = {}): FunctionContext {\n return {\n method: 'GET',\n path: '/auth/me',\n query: {},\n headers: {},\n body: undefined,\n files: [],\n cloud: { db } as FunctionContext['cloud'],\n user: null,\n ...overrides,\n }\n}\n\ndescribe('deviceIdOfOidcUser', () => {\n it('\u4EE5 `oidc:` \u524D\u7F00 + \u5E73\u53F0\u7528\u6237 id \u7EC4\u6210\u672C\u5730 device_id', () => {\n expect(deviceIdOfOidcUser({ id: 'plat-123' })).toBe(`${OIDC_DEVICE_PREFIX}plat-123`)\n })\n})\n\ndescribe('resolveUser \xB7 OIDC \u8EAB\u4EFD\uFF08ctx.user\uFF09', () => {\n it('\u9996\u6B21\u8BBF\u95EE\u81EA\u52A8\u5EFA\u884C\uFF08\u6620\u5C04\u5230 oidc:<\u5E73\u53F0\u7528\u6237 id>\uFF0C\u6635\u79F0\u53D6 ctx.user.name\uFF09', async () => {\n const { db, users } = makeMemoryDb()\n const ctx = ctxOf(db, { user: { id: 'plat-1', name: '\u5C0F\u660E', email: 'm@x.com' } })\n\n const user = await resolveUser(db, ctx)\n expect(user?.device_id).toBe('oidc:plat-1')\n expect(user?.nickname).toBe('\u5C0F\u660E')\n expect(users).toHaveLength(1)\n\n // \u5DF2\u5EFA\u884C\uFF1A\u518D\u6B21\u8BBF\u95EE\u590D\u7528\u540C\u4E00\u884C\uFF0C\u4E0D\u91CD\u590D\u5EFA\n const again = await resolveUser(db, ctx)\n expect(again?.id).toBe(user?.id)\n expect(users).toHaveLength(1)\n })\n\n it('\u7F3A name \u65F6\u6635\u79F0\u56DE\u9000\u300C\u5E73\u53F0\u7528\u6237\u300D', async () => {\n const { db, users } = makeMemoryDb()\n const user = await resolveUser(db, ctxOf(db, { user: { id: 'plat-2' } }))\n expect(user?.nickname).toBe('\u5E73\u53F0\u7528\u6237')\n expect(users).toHaveLength(1)\n })\n\n it('\u5DF2\u5B58\u5728\u7684 OIDC \u6620\u5C04\u884C\u76F4\u63A5\u547D\u4E2D\uFF08\u4E0D\u67E5 device Bearer\uFF09', async () => {\n const existing: UserRow = {\n id: 'local-9',\n device_id: 'oidc:plat-9',\n nickname: '\u8001\u7528\u6237',\n avatar_url: '',\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n }\n const { db, users } = makeMemoryDb([existing])\n const user = await resolveUser(db, ctxOf(db, { user: { id: 'plat-9' } }))\n expect(user?.id).toBe('local-9')\n expect(users).toHaveLength(1)\n })\n})\n\ndescribe('resolveUser \xB7 \u8BBE\u5907\u8EAB\u4EFD\uFF08Bearer device_id\uFF09', () => {\n it('\u5408\u6CD5 device_id \u547D\u4E2D\u7528\u6237', async () => {\n const row: UserRow = {\n id: 'local-1',\n device_id: 'dev-abc-123',\n nickname: '\u672C\u5730\u7528\u6237',\n avatar_url: '',\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n }\n const { db } = makeMemoryDb([row])\n const ctx = ctxOf(db, {\n headers: { authorization: 'Bearer dev-abc-123' },\n user: null,\n })\n const user = await resolveUser(db, ctx)\n expect(user?.id).toBe('local-1')\n })\n\n it('\u65E0\u51ED\u636E \u2192 null\uFF08\u8C03\u7528\u65B9\u51B3\u5B9A\u5931\u8D25\u54CD\u5E94\uFF09', async () => {\n const { db } = makeMemoryDb()\n expect(await resolveUser(db, ctxOf(db))).toBeNull()\n })\n\n it('token \u67E5\u65E0\u7528\u6237 \u2192 null', async () => {\n const { db } = makeMemoryDb()\n expect(\n await resolveUser(db, ctxOf(db, { headers: { authorization: 'Bearer ghost' } }))\n ).toBeNull()\n })\n})\n\ndescribe('authGuard \xB7 \u4E2D\u95F4\u4EF6\u5F62\u6001\uFF08FN-029\uFF09', () => {\n it('\u767B\u5F55\u6210\u529F\uFF1A\u7528\u6237\u884C\u5199\u5165 req.state\uFF0C\u8FD4\u56DE undefined \u653E\u884C', async () => {\n const { db } = makeMemoryDb([\n {\n id: 'local-1',\n device_id: 'dev-abc-123',\n nickname: '\u672C\u5730\u7528\u6237',\n avatar_url: '',\n created_at: null,\n updated_at: null,\n },\n ])\n const guard = authGuard()\n const req = {\n method: 'GET',\n path: '/events',\n params: {},\n query: {},\n raw: ctxOf(db, { headers: { authorization: 'Bearer dev-abc-123' } }),\n state: {} as Record<string, unknown>,\n }\n const short = await guard(req)\n expect(short).toBeUndefined()\n expect((req.state.user as UserRow).id).toBe('local-1')\n })\n\n it('\u672A\u767B\u5F55\uFF1A\u77ED\u8DEF\u8FD4\u56DE 40001 \u4FE1\u5C01\uFF08401 + code 40001\uFF09', async () => {\n const { db } = makeMemoryDb()\n const guard = authGuard()\n const short = await guard({\n method: 'GET',\n path: '/events',\n params: {},\n query: {},\n raw: ctxOf(db),\n state: {},\n })\n expect(isAdepHttpEnvelope(short)).toBe(true)\n if (isAdepHttpEnvelope(short)) {\n expect(short.__adepHttp.status).toBe(401)\n expect(short.__adepHttp.body).toMatchObject({ code: 40001, data: null })\n }\n })\n})\n\ndescribe('parseBearer', () => {\n it('\u5927\u5C0F\u5199 / \u591A\u4F59\u7A7A\u683C\u5BB9\u9519', () => {\n const mk = () => makeMemoryDb().db\n expect(parseBearer(ctxOf(mk(), { headers: { authorization: 'Bearer abc' } }))).toBe('abc')\n expect(parseBearer(ctxOf(mk(), { headers: { Authorization: ' bearer xyz ' } }))).toBe('xyz')\n expect(parseBearer(ctxOf(mk()))).toBe('')\n })\n})\n\ndescribe('store \xB7 OIDC \u6620\u5C04\u884C\u53EF\u88AB\u67E5\u8BE2', () => {\n it('findUserByDeviceId \u80FD\u627E\u5230 oidc: \u524D\u7F00\u884C\uFF08\u51FD\u6570\u5C42\u4E0E\u6570\u636E\u5C42\u4E00\u81F4\uFF09', async () => {\n const { db, users } = makeMemoryDb()\n const ctx = ctxOf(db, { user: { id: 'plat-7', name: '\u6D4B' } })\n const user = await resolveUser(db, ctx)\n expect(user).not.toBeNull()\n const found = await findUserByDeviceId(db, user?.device_id ?? '')\n expect(found?.id).toBe(user?.id)\n expect(users.some((u) => u.device_id.startsWith('oidc:'))).toBe(true)\n })\n})\n"
613
613
  },
614
614
  {
615
615
  "path": "functions/_shared/auth.ts",
616
- "content": "/**\n * \u8BF7\u6C42\u9274\u6743\uFF08\u79FB\u690D\u81EA cf-backend src/middleware/auth.ts + src/lib/jwt.ts \u7684\u8BED\u4E49\uFF09\u3002\n *\n * \u53CC\u8EAB\u4EFD\u6765\u6E90\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u63A5\u7EBF\u89C1 apps/countdown README\u300C\u63A5\u5165\u5E73\u53F0\u767B\u5F55\u300D\uFF09\uFF1A\n * 1. OIDC \u8EAB\u4EFD\uFF08\u4F18\u5148\uFF09\uFF1A\u7F51\u5173\u628A `Authorization: Bearer <oauth token>` \u89E3\u6790\u6210\n * `ctx.user`\uFF08\u5E73\u53F0\u7528\u6237\uFF0C\u89C1 server/wiring.ts resolveUser\uFF09\u3002\u672C\u5730 users \u8868\u7684\n * device_id \u590D\u7528\u4E3A `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u5173\u8054\uFF08\u96F6\u8FC1\u79FB\uFF0C\u65E0\u9700\u6539\u8868\uFF09\uFF0C\u9996\u6B21\u8BBF\u95EE\u81EA\u52A8\u5EFA\u884C\u3002\n * 2. \u8BBE\u5907\u8EAB\u4EFD\uFF08\u517C\u5BB9\u65E7\u5BA2\u6237\u7AEF / API \u8C03\u7528\uFF09\uFF1A`POST /auth/login` \u6362\u53D6 device_id \u4F5C Bearer\n * \u51ED\u636E\uFF08web \u524D\u7AEF\u5DF2\u8FC1\u79FB OIDC \u767B\u5F55\uFF0C\u4E0D\u518D\u4F7F\u7528\uFF09\u3002device_id \u5373\u7528\u6237\u8868\u552F\u4E00\u952E\uFF0C\u7B49\u4EF7 API\n * Key\uFF1A\u67E5\u5F97\u5230 \u2192 \u8EAB\u4EFD\u6210\u7ACB\u3002\n *\n * \u5931\u8D25\u8FD4\u56DE\u4E0E\u5C0F\u7A0B\u5E8F\u540E\u7AEF\u4E00\u81F4\u7684\u4E1A\u52A1\u7801 40001\uFF08\u524D\u7AEF request.ts \u4F1A\u636E\u6B64\u9759\u9ED8\u91CD\u767B\uFF09\u3002\n */\nimport type { CloudDb, FunctionContext } from '@adep/types'\nimport { fail, CODE } from './response'\nimport { findUserByDeviceId, createUser, type NewUserRow, type UserRow } from './store'\nimport { nowIso } from './date'\n\n/** OIDC \u7528\u6237\u5728\u672C\u5730 users \u8868 device_id \u7684\u5B58\u50A8\u524D\u7F00\uFF08`oidc:<\u5E73\u53F0\u7528\u6237 id>`\uFF09\u3002 */\nexport const OIDC_DEVICE_PREFIX = 'oidc:'\n\n/** \u5E73\u53F0\u7528\u6237\uFF08ctx.user\uFF09\u2192 \u672C\u5730 device_id\uFF08\u96F6\u8FC1\u79FB\u590D\u7528\u552F\u4E00\u952E\uFF09\u3002 */\nexport function deviceIdOfOidcUser(user: { id: string }): string {\n return `${OIDC_DEVICE_PREFIX}${user.id}`\n}\n\n/** \u89E3\u6790 Authorization: Bearer xxx\uFF08\u5E26\u524D\u5BFC\u5927\u5C0F\u5199/\u591A\u4F59\u7A7A\u683C\u5BB9\u9519\uFF09 */\nexport function parseBearer(ctx: FunctionContext): string {\n const header = ctx.headers?.authorization ?? ctx.headers?.Authorization ?? ''\n const match = /^Bearer\\s+(.+)$/i.exec(String(header).trim())\n return match?.[1]?.trim() ?? ''\n}\n\n/**\n * \u4ECE\u8BF7\u6C42\u89E3\u6790\u51FA\u7528\u6237\uFF1B\u7F3A\u51ED\u636E/\u7528\u6237\u4E0D\u5B58\u5728 \u2192 40001 \u54CD\u5E94\u4FE1\u5C01\u3002\n *\n * \u767B\u5F55\u5373\u670D\u52A1\uFF1AOIDC \u8EAB\u4EFD\uFF08ctx.user\uFF09\u4F18\u5148\u2014\u2014\u7F51\u5173\u5DF2\u628A OAuth Bearer \u89E3\u6790\u6210\u5E73\u53F0\u7528\u6237\uFF0C\n * \u672C\u5730\u884C\u4EE5 `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u5173\u8054\uFF0C\u7F3A\u884C\u5373\u5EFA\uFF08\u5E42\u7B49\uFF1B\u6635\u79F0\u4EC5\u5EFA\u884C\u65F6\u5199\u5165\uFF0C\u4E0D\u56DE\u5199\uFF09\uFF1B\n * \u672A\u547D\u4E2D\u518D\u56DE\u9000 device_id \u51ED\u636E\uFF08web \u7AEF\u672A\u767B\u5F55\u7684\u672C\u5730\u8EAB\u4EFD\uFF09\u3002\n */\nexport async function requireUser(\n db: CloudDb,\n ctx: FunctionContext\n): Promise<{ user: UserRow } | AdepHttpEnvelopeLike> {\n if (ctx.user !== null) {\n const deviceId = deviceIdOfOidcUser(ctx.user)\n let user = await findUserByDeviceId(db, deviceId)\n if (user === null) {\n const fresh: NewUserRow = {\n id: crypto.randomUUID(),\n device_id: deviceId,\n nickname: ctx.user.name ?? '\u5E73\u53F0\u7528\u6237',\n avatar_url: '',\n created_at: nowIso(),\n updated_at: nowIso(),\n }\n await createUser(db, fresh)\n user = fresh\n }\n return { user }\n }\n\n const token = parseBearer(ctx)\n if (!token) return fail(CODE.NO_AUTH, '\u672A\u767B\u5F55')\n const user = await findUserByDeviceId(db, token)\n if (!user) return fail(CODE.NO_AUTH, '\u767B\u5F55\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55')\n return { user }\n}\n\n/** \u5224\u65AD\u8FD4\u56DE\u662F\u5426\u54CD\u5E94\u4FE1\u5C01\uFF08\u7528\u4E8E requireUser \u6536\u7A84\uFF09\u3002 */\nexport function isEnvelope<T>(value: T | AdepHttpEnvelopeLike): value is AdepHttpEnvelopeLike {\n return typeof value === 'object' && value !== null && '__adepHttp' in value\n}\n\nexport interface AdepHttpEnvelopeLike {\n __adepHttp: { status: number; body?: unknown }\n}\n"
616
+ "content": "/**\n * \u8BF7\u6C42\u9274\u6743\uFF08\u79FB\u690D\u81EA cf-backend src/middleware/auth.ts + src/lib/jwt.ts \u7684\u8BED\u4E49\uFF09\u3002\n *\n * \u53CC\u8EAB\u4EFD\u6765\u6E90\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u63A5\u7EBF\u89C1\u6A21\u677F README\u300C\u7528\u6237\u4F53\u7CFB\u8BF4\u660E\u300D\uFF09\uFF1A\n * 1. OIDC \u8EAB\u4EFD\uFF08\u4F18\u5148\uFF09\uFF1A\u7F51\u5173\u628A `Authorization: Bearer <oauth token>` \u89E3\u6790\u6210\n * `ctx.user`\uFF08\u5E73\u53F0\u7528\u6237\uFF0C\u89C1 server/wiring.ts resolveUser\uFF09\u3002\u672C\u5730 users \u8868\u7684\n * device_id \u590D\u7528\u4E3A `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u5173\u8054\uFF08\u96F6\u8FC1\u79FB\uFF0C\u65E0\u9700\u6539\u8868\uFF09\uFF0C\u9996\u6B21\u8BBF\u95EE\u81EA\u52A8\u5EFA\u884C\u3002\n * 2. \u8BBE\u5907\u8EAB\u4EFD\uFF08\u517C\u5BB9\u65E7\u5BA2\u6237\u7AEF / API \u8C03\u7528\uFF09\uFF1A`POST /auth/login` \u6362\u53D6 device_id \u4F5C Bearer\n * \u51ED\u636E\uFF08web \u524D\u7AEF\u5DF2\u8FC1\u79FB OIDC \u767B\u5F55\uFF0C\u4E0D\u518D\u4F7F\u7528\uFF09\u3002device_id \u5373\u7528\u6237\u8868\u552F\u4E00\u952E\uFF0C\u7B49\u4EF7 API\n * Key\uFF1A\u67E5\u5F97\u5230 \u2192 \u8EAB\u4EFD\u6210\u7ACB\u3002\n *\n * FN-029 \u8D77\u4EE5**\u8DEF\u7531\u4E2D\u95F4\u4EF6**\u5F62\u6001\u63D0\u4F9B\uFF08`authGuard`\uFF09\uFF1A\u6CE8\u518C\u4E00\u6B21\uFF0C\u4E1A\u52A1 handler \u91CC\n * `req.state.user` \u76F4\u63A5\u62FF\u5230\u7528\u6237\u884C\u2014\u2014\u4E0D\u518D\u6BCF\u4E2A\u7AEF\u70B9\u624B\u5199 `isEnvelope` \u6536\u7A84\u6837\u677F\u3002\n * \u5931\u8D25\u77ED\u8DEF\u8FD4\u56DE\u4E0E\u5C0F\u7A0B\u5E8F\u540E\u7AEF\u4E00\u81F4\u7684\u4E1A\u52A1\u7801 40001\uFF08\u524D\u7AEF request.ts \u4F1A\u636E\u6B64\u9759\u9ED8\u91CD\u767B\uFF09\u3002\n */\nimport type { CloudDb, FunctionContext } from '@adep/types'\nimport { fail, CODE } from '@adep/runtime/response'\nimport type { RouteMiddleware } from '@adep/runtime/route'\nimport { findUserByDeviceId, createUser, type NewUserRow, type UserRow } from './store'\nimport { nowIso } from './date'\n\n/** OIDC \u7528\u6237\u5728\u672C\u5730 users \u8868 device_id \u7684\u5B58\u50A8\u524D\u7F00\uFF08`oidc:<\u5E73\u53F0\u7528\u6237 id>`\uFF09\u3002 */\nexport const OIDC_DEVICE_PREFIX = 'oidc:'\n\n/** \u5E73\u53F0\u7528\u6237\uFF08ctx.user\uFF09\u2192 \u672C\u5730 device_id\uFF08\u96F6\u8FC1\u79FB\u590D\u7528\u552F\u4E00\u952E\uFF09\u3002 */\nexport function deviceIdOfOidcUser(user: { id: string }): string {\n return `${OIDC_DEVICE_PREFIX}${user.id}`\n}\n\n/** \u89E3\u6790 Authorization: Bearer xxx\uFF08\u5E26\u524D\u5BFC\u5927\u5C0F\u5199/\u591A\u4F59\u7A7A\u683C\u5BB9\u9519\uFF09 */\nexport function parseBearer(ctx: FunctionContext): string {\n const header = ctx.headers?.authorization ?? ctx.headers?.Authorization ?? ''\n const match = /^Bearer\\s+(.+)$/i.exec(String(header).trim())\n return match?.[1]?.trim() ?? ''\n}\n\n/**\n * \u4ECE\u8BF7\u6C42\u89E3\u6790\u51FA\u7528\u6237\uFF1B\u7F3A\u51ED\u636E/\u7528\u6237\u4E0D\u5B58\u5728 \u2192 null\uFF08\u8C03\u7528\u65B9\u81EA\u884C\u51B3\u5B9A\u5931\u8D25\u54CD\u5E94\uFF09\u3002\n *\n * \u767B\u5F55\u5373\u670D\u52A1\uFF1AOIDC \u8EAB\u4EFD\uFF08ctx.user\uFF09\u4F18\u5148\u2014\u2014\u7F51\u5173\u5DF2\u628A OAuth Bearer \u89E3\u6790\u6210\u5E73\u53F0\u7528\u6237\uFF0C\n * \u672C\u5730\u884C\u4EE5 `oidc:<\u5E73\u53F0\u7528\u6237 id>` \u5173\u8054\uFF0C\u7F3A\u884C\u5373\u5EFA\uFF08\u5E42\u7B49\uFF1B\u6635\u79F0\u4EC5\u5EFA\u884C\u65F6\u5199\u5165\uFF0C\u4E0D\u56DE\u5199\uFF09\uFF1B\n * \u672A\u547D\u4E2D\u518D\u56DE\u9000 device_id \u51ED\u636E\uFF08web \u7AEF\u672A\u767B\u5F55\u7684\u672C\u5730\u8EAB\u4EFD\uFF09\u3002\n */\nexport async function resolveUser(db: CloudDb, ctx: FunctionContext): Promise<UserRow | null> {\n if (ctx.user !== null) {\n const deviceId = deviceIdOfOidcUser(ctx.user)\n let user = await findUserByDeviceId(db, deviceId)\n if (user === null) {\n const fresh: NewUserRow = {\n id: crypto.randomUUID(),\n device_id: deviceId,\n nickname: ctx.user.name ?? '\u5E73\u53F0\u7528\u6237',\n avatar_url: '',\n created_at: nowIso(),\n updated_at: nowIso(),\n }\n await createUser(db, fresh)\n user = fresh\n }\n return user\n }\n\n const token = parseBearer(ctx)\n if (!token) return null\n return findUserByDeviceId(db, token)\n}\n\n/**\n * \u9274\u6743\u4E2D\u95F4\u4EF6\uFF1A\u89E3\u6790\u5931\u8D25\u77ED\u8DEF 401\uFF0840001 \u4FE1\u5C01\uFF09\uFF1B\u6210\u529F\u628A\u7528\u6237\u884C\u653E\u8FDB `req.state.user`\u3002\n *\n * db \u76F4\u63A5\u4ECE `req.raw`\uFF08FunctionContext\uFF09\u53D6\u2014\u2014\u4E2D\u95F4\u4EF6\u4E0E handler \u770B\u5230\u7684\u662F\u540C\u4E00\u4E2A ctx\uFF0C\n * \u4E0D\u5FC5\u5404\u81EA\u4F20\u4E00\u904D\u3002\u7528\u6CD5\uFF1A`router.use(authGuard())`\uFF0C\u6CE8\u518C\u4E4B\u540E\u7684\u6240\u6709\u8DEF\u7531\uFF0Chandler \u91CC\n * `req.state.user as UserRow` \u5373\u5DF2\u767B\u5F55\u7528\u6237\uFF08\u4E1A\u52A1\u51FD\u6570\u91CC\u4E0D\u518D\u51FA\u73B0\u4FE1\u5C01\u6536\u7A84\u6837\u677F\uFF09\u3002\n */\nexport function authGuard(): RouteMiddleware<FunctionContext> {\n return async (req) => {\n const ctx = req.raw\n const db = ctx.cloud.db\n const user = await resolveUser(db, ctx)\n if (user === null) {\n return fail(\n CODE.NO_AUTH,\n parseBearer(ctx) === '' ? '\u672A\u767B\u5F55' : '\u767B\u5F55\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55'\n )\n }\n req.state.user = user\n return undefined\n }\n}\n"
617
617
  },
618
618
  {
619
619
  "path": "functions/_shared/date.test.ts",
620
- "content": "/**\n * \u670D\u52A1\u7AEF\u65E5\u671F/\u6392\u5E8F/\u54CD\u5E94\u4FE1\u5C01\u5355\u5143\u6D4B\u8BD5\uFF08\u79FB\u690D\u903B\u8F91\u7684\u56DE\u5F52\u4FDD\u62A4\uFF09\u3002\n * \u8FD0\u884C\uFF1Anpm run test\n */\nimport { describe, it, expect } from 'vitest'\nimport { isValidDate, calcDays, toDTO, sortEvents, type EventRow } from './date'\nimport { ok, fail, CODE } from './response'\n\ndescribe('isValidDate', () => {\n it('\u63A5\u53D7\u5408\u6CD5 YYYY-MM-DD', () => {\n expect(isValidDate('2026-09-20')).toBe(true)\n expect(isValidDate('2024-02-29')).toBe(true)\n })\n it('\u62D2\u7EDD\u975E\u6CD5\u65E5\u671F\u4E0E\u683C\u5F0F', () => {\n expect(isValidDate('2026-13-01')).toBe(false)\n expect(isValidDate('2026-02-30')).toBe(false)\n expect(isValidDate('2026/09/20')).toBe(false)\n expect(isValidDate('')).toBe(false)\n expect(isValidDate(null)).toBe(false)\n })\n})\n\ndescribe('calcDays\uFF08\u5317\u4EAC\u65F6\u95F4\u81EA\u7136\u65E5\uFF09', () => {\n it('\u5012\u8BA1\u65F6\uFF1A\u672A\u6765\u4E3A\u6B63\uFF0C\u5F53\u5929\u4E3A 0', () => {\n const today = new Date()\n const beijing = new Date(today.getTime() + 8 * 3600_000)\n const todayStr = `${beijing.getUTCFullYear()}-${String(beijing.getUTCMonth() + 1).padStart(2, '0')}-${String(\n beijing.getUTCDate()\n ).padStart(2, '0')}`\n expect(calcDays(todayStr)).toBe(0)\n })\n it('\u8FC7\u53BB\u65E5\u671F\u4E3A\u8D1F\uFF1Bcountup \u53D6\u7EDD\u5BF9\u503C', () => {\n expect(calcDays('2020-01-01')).toBeLessThan(0)\n expect(calcDays('2020-01-01', 'countup')).toBeGreaterThan(0)\n })\n it('\u975E\u6CD5\u65E5\u671F\u8FD4\u56DE 0', () => {\n expect(calcDays('bad-date')).toBe(0)\n })\n})\n\ndescribe('toDTO + sortEvents', () => {\n const base = {\n id: '1',\n user_id: 'u1',\n title: '\u4E8B\u4EF6',\n target_date: '2030-01-01',\n note: null,\n category: null,\n direction: 'countdown',\n is_pinned: 0,\n sort_order: 0,\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n } as EventRow\n\n it('toDTO \u8865\u9ED8\u8BA4\u503C\u5E76\u7B97 days', () => {\n const dto = toDTO(base)\n expect(dto.note).toBe('')\n expect(dto.category).toBe('other')\n expect(dto.is_pinned).toBe(false)\n expect(dto.days).toBeGreaterThan(0)\n })\n\n it('\u6392\u5E8F\uFF1A\u7F6E\u9876\u4F18\u5148\uFF0C\u5176\u4F59\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\uFF08\u8FC7\u53BB\u6392\u6700\u540E\uFF09', () => {\n const far = { ...base, id: 'far', target_date: '2031-01-01' } as EventRow\n const near = { ...base, id: 'near', target_date: '2026-10-01' } as EventRow\n const past = { ...base, id: 'past', target_date: '2020-01-01' } as EventRow\n const pinnedFar = { ...far, id: 'pinnedFar', is_pinned: 1 } as EventRow\n const sorted = sortEvents([far, near, past, pinnedFar].map((r) => toDTO(r)))\n expect(sorted.map((e) => e.id)).toEqual(['pinnedFar', 'near', 'far', 'past'])\n })\n})\n\ndescribe('\u54CD\u5E94\u4FE1\u5C01', () => {\n it('ok \u8FD4\u56DE 200 + \u4E1A\u52A1\u4FE1\u5C01', () => {\n const env = ok({ a: 1 })\n expect(env.__adepHttp.status).toBe(200)\n expect(env.__adepHttp.body).toEqual({ code: 0, data: { a: 1 }, message: 'ok' })\n })\n it('fail \u6620\u5C04 HTTP \u72B6\u6001', () => {\n expect(fail(CODE.NO_AUTH, 'x').__adepHttp.status).toBe(401)\n expect(fail(CODE.BAD_PARAM, 'x').__adepHttp.status).toBe(400)\n expect(fail(CODE.NOT_FOUND, 'x').__adepHttp.status).toBe(404)\n })\n})\n"
620
+ "content": "/**\n * \u670D\u52A1\u7AEF\u65E5\u671F/\u6392\u5E8F\u5355\u5143\u6D4B\u8BD5\uFF08\u79FB\u690D\u903B\u8F91\u7684\u56DE\u5F52\u4FDD\u62A4\uFF09\u3002\n * \u54CD\u5E94\u4FE1\u5C01\u7684\u6D4B\u8BD5\u5728\u5B98\u65B9\u5305 `@adep/runtime` \u4FA7\uFF08FN-029 \u8D77 ok/fail \u6536\u7F16\u8FDB\u5E73\u53F0\uFF09\u3002\n * \u8FD0\u884C\uFF1Anpm run test\n */\nimport { describe, it, expect } from 'vitest'\nimport { isValidDate, calcDays, toDTO, sortEvents } from './date'\nimport type { EventRow } from './store'\n\ndescribe('isValidDate', () => {\n it('\u63A5\u53D7\u5408\u6CD5 YYYY-MM-DD', () => {\n expect(isValidDate('2026-09-20')).toBe(true)\n expect(isValidDate('2024-02-29')).toBe(true)\n })\n it('\u62D2\u7EDD\u975E\u6CD5\u65E5\u671F\u4E0E\u683C\u5F0F', () => {\n expect(isValidDate('2026-13-01')).toBe(false)\n expect(isValidDate('2026-02-30')).toBe(false)\n expect(isValidDate('2026/09/20')).toBe(false)\n expect(isValidDate('')).toBe(false)\n expect(isValidDate(null)).toBe(false)\n })\n})\n\ndescribe('calcDays\uFF08\u5317\u4EAC\u65F6\u95F4\u81EA\u7136\u65E5\uFF09', () => {\n it('\u5012\u8BA1\u65F6\uFF1A\u672A\u6765\u4E3A\u6B63\uFF0C\u5F53\u5929\u4E3A 0', () => {\n const today = new Date()\n const beijing = new Date(today.getTime() + 8 * 3600_000)\n const todayStr = `${beijing.getUTCFullYear()}-${String(beijing.getUTCMonth() + 1).padStart(2, '0')}-${String(\n beijing.getUTCDate()\n ).padStart(2, '0')}`\n expect(calcDays(todayStr)).toBe(0)\n })\n it('\u8FC7\u53BB\u65E5\u671F\u4E3A\u8D1F\uFF1Bcountup \u53D6\u7EDD\u5BF9\u503C', () => {\n expect(calcDays('2020-01-01')).toBeLessThan(0)\n expect(calcDays('2020-01-01', 'countup')).toBeGreaterThan(0)\n })\n it('\u975E\u6CD5\u65E5\u671F\u8FD4\u56DE 0', () => {\n expect(calcDays('bad-date')).toBe(0)\n })\n})\n\ndescribe('toDTO + sortEvents', () => {\n const base = {\n id: '1',\n user_id: 'u1',\n title: '\u4E8B\u4EF6',\n target_date: '2030-01-01',\n note: null,\n category: null,\n direction: 'countdown',\n is_pinned: 0,\n sort_order: 0,\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n } as EventRow\n\n it('toDTO \u8865\u9ED8\u8BA4\u503C\u5E76\u7B97 days', () => {\n const dto = toDTO(base)\n expect(dto.note).toBe('')\n expect(dto.category).toBe('other')\n expect(dto.is_pinned).toBe(false)\n expect(dto.days).toBeGreaterThan(0)\n })\n\n it('\u6392\u5E8F\uFF1A\u7F6E\u9876\u4F18\u5148\uFF0C\u5176\u4F59\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\uFF08\u8FC7\u53BB\u6392\u6700\u540E\uFF09', () => {\n const far = { ...base, id: 'far', target_date: '2031-01-01' } as EventRow\n const near = { ...base, id: 'near', target_date: '2026-10-01' } as EventRow\n const past = { ...base, id: 'past', target_date: '2020-01-01' } as EventRow\n const pinnedFar = { ...far, id: 'pinnedFar', is_pinned: 1 } as EventRow\n const sorted = sortEvents([far, near, past, pinnedFar].map((r) => toDTO(r)))\n expect(sorted.map((e) => e.id)).toEqual(['pinnedFar', 'near', 'far', 'past'])\n })\n})\n"
621
621
  },
622
622
  {
623
623
  "path": "functions/_shared/date.ts",
624
- "content": "/**\n * \u670D\u52A1\u7AEF\u65E5\u671F\u5DE5\u5177\uFF08\u79FB\u690D\u81EA cf-backend src/lib/date.ts\uFF09\u3002\n * \u4E0E\u5C0F\u7A0B\u5E8F utils/date.js \u7B97\u6CD5\u5BF9\u9F50\uFF1A\u5929\u6570\u6309\u300C\u5317\u4EAC\u65F6\u95F4\uFF08UTC+8\uFF09\u81EA\u7136\u65E5\u300D\u8BA1\u7B97\u3002\n */\nconst WEEK = ['\u5468\u65E5', '\u5468\u4E00', '\u5468\u4E8C', '\u5468\u4E09', '\u5468\u56DB', '\u5468\u4E94', '\u5468\u516D']\n\n/** \u5317\u4EAC\u65F6\u95F4\u4ECA\u5929 YYYY-MM-DD\uFF08\u81EA\u7136\u65E5\uFF0C\u4E0D\u4EE5 UTC \u65E5\u671F\u4E3A\u51C6\uFF09 */\nexport function todayStr(): string {\n const now = new Date(Date.now() + 8 * 3600_000)\n const y = now.getUTCFullYear()\n const m = now.getUTCMonth() + 1\n const d = now.getUTCDate()\n return fmt(y, m, d)\n}\n\nfunction fmt(y: number, m: number, d: number): string {\n return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`\n}\n\nexport function isValidDate(s: unknown): s is string {\n if (typeof s !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return false\n const [y, m, d] = s.split('-').map(Number)\n if (m < 1 || m > 12 || d < 1 || d > 31) return false\n const dt = new Date(y, m - 1, d)\n return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d\n}\n\n/** \u6309\u5317\u4EAC\u65F6\u95F4\u81EA\u7136\u65E5\u5DEE\uFF08\u6B63\u6570\u672A\u6765 / \u8D1F\u6570\u8FC7\u53BB\uFF1Bcountup \u53D6\u7EDD\u5BF9\u503C\uFF09 */\nexport function calcDays(\n targetDate: string,\n direction: 'countdown' | 'countup' = 'countdown'\n): number {\n if (!isValidDate(targetDate)) return 0\n const target = new Date(`${targetDate}T00:00:00+08:00`).getTime()\n const now = new Date()\n const nowBeijing = new Date(now.getTime() + 8 * 3600_000)\n const todayStart = new Date(\n Date.UTC(nowBeijing.getUTCFullYear(), nowBeijing.getUTCMonth(), nowBeijing.getUTCDate()) -\n 8 * 3600_000\n ).getTime()\n const diff = Math.round((target - todayStart) / 86400000)\n return direction === 'countup' ? Math.abs(diff) : diff\n}\n\nexport function weekdayOf(targetDate: string): string {\n if (!isValidDate(targetDate)) return ''\n const [y, m, d] = targetDate.split('-').map(Number)\n return WEEK[new Date(y, m - 1, d).getDay()]\n}\n\n/** \u670D\u52A1\u5668\u65F6\u95F4 ISO\uFF08UTC\uFF0C\u5B58\u50A8\u7528\uFF09 */\nexport function nowIso(): string {\n return new Date().toISOString()\n}\n\n/** \u4E8B\u4EF6\u884C \u2192 DTO\uFF08\u9644 days / weekday\uFF1Bis_pinned \u5E03\u5C14\u5316\uFF09 */\nexport interface EventRow {\n id: string\n user_id: string\n title: string\n target_date: string\n note: string | null\n category: string | null\n direction: 'countdown' | 'countup' | null\n is_pinned: number | boolean | null\n sort_order: number | null\n created_at: string | null\n updated_at: string | null\n}\n\nexport interface EventDTO {\n id: string\n title: string\n target_date: string\n note: string\n category: string\n direction: 'countdown' | 'countup'\n is_pinned: boolean\n sort_order: number\n created_at: string\n updated_at: string\n days: number\n}\n\nexport function toDTO(row: EventRow): EventDTO {\n const direction = row.direction === 'countup' ? 'countup' : 'countdown'\n const dto: EventDTO = {\n id: row.id,\n title: row.title,\n target_date: row.target_date,\n note: row.note ?? '',\n category: row.category ?? 'other',\n direction,\n is_pinned: !!row.is_pinned,\n sort_order: row.sort_order ?? 0,\n created_at: row.created_at ?? '',\n updated_at: row.updated_at ?? '',\n days: 0,\n }\n dto.days = calcDays(row.target_date, direction)\n return dto\n}\n\n/** \u5217\u8868\u6392\u5E8F\uFF1A\u7F6E\u9876\u5728\u524D\uFF0C\u5176\u4F59\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\uFF08\u8FC7\u53BB\u7684\u5929\u6570\u6392\u5728\u6700\u540E\uFF09 */\nexport function sortEvents<T extends EventDTO>(events: T[]): T[] {\n return events.toSorted((a, b) => {\n if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1\n const da = a.days >= 0 ? a.days : Number.MAX_SAFE_INTEGER\n const db = b.days >= 0 ? b.days : Number.MAX_SAFE_INTEGER\n return da - db\n })\n}\n"
625
- },
626
- {
627
- "path": "functions/_shared/response.ts",
628
- "content": "/**\n * \u7EDF\u4E00\u54CD\u5E94\u4FE1\u5C01\uFF08\u9010\u884C\u79FB\u690D\u81EA cf-backend src/lib/response.ts\uFF0C\u8F93\u51FA adep `__adepHttp` \u4FE1\u5C01\uFF09\u3002\n *\n * \u5C0F\u7A0B\u5E8F\u540E\u7AEF\u7528 Hono \u8FD4\u56DE `{code, data, message}` + HTTP \u72B6\u6001\uFF1Badep \u4E91\u51FD\u6570\u8FD4\u56DE\n * `__adepHttp` \u4FE1\u5C01\u5373\u53EF\u83B7\u5F97\u540C\u6837\u7684 status/body \u8BED\u4E49\uFF08ADR-0024 / CF-010\uFF0C\n * \u672C\u5730 dev \u4E0E\u5E73\u53F0\u7F51\u5173\u540C\u4E00\u5957\u8BC6\u522B\u903B\u8F91\uFF09\u3002\n */\nimport type { AdepHttpEnvelope } from '@adep/types'\n\nexport const CODE = {\n OK: 0,\n NO_AUTH: 40001,\n BAD_PARAM: 40002,\n NOT_FOUND: 40003,\n LIMIT_EXCEEDED: 40004,\n SERVER_ERROR: 50000,\n} as const\n\nexport type CodeValue = (typeof CODE)[keyof typeof CODE]\n\nconst HTTP_STATUS: Record<number, number> = {\n 0: 200,\n 40001: 401,\n 40002: 400,\n 40003: 404,\n 40004: 409,\n 50000: 500,\n}\n\nexport interface ApiBody<T> {\n code: number\n data: T | null\n message: string\n}\n\nconst JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }\n\nfunction envelope<T>(body: ApiBody<T>, status: number): AdepHttpEnvelope {\n return {\n __adepHttp: {\n status,\n headers: JSON_HEADERS,\n body,\n },\n }\n}\n\nexport function ok<T>(data: T, message = 'ok'): AdepHttpEnvelope {\n return envelope({ code: CODE.OK, data, message }, 200)\n}\n\nexport function fail(code: CodeValue, message: string): AdepHttpEnvelope {\n return envelope({ code, data: null, message }, HTTP_STATUS[code] ?? 400)\n}\n"
629
- },
630
- {
631
- "path": "functions/_shared/route.ts",
632
- "content": "/**\n * \u8DEF\u5F84\u5F52\u4E00\u5316\uFF1A\u5E73\u53F0\u7F51\u5173 / CLI dev \u4F20\u7ED9\u51FD\u6570\u7684 ctx.path \u662F\u5B8C\u6574 pathname\uFF08\u542B\n * functions_prefix /api\uFF0C\u89C1 AGENTS.md v1.57 \u8FB9\u754C\u8BF4\u660E\uFF09\u3002\u8FD9\u91CC\u5265\u6389\u524D\u7F00\uFF0C\u51FD\u6570\u5185\n * \u4E00\u5F8B\u6309\u300C\u524D\u7F00\u540E\u8DEF\u5F84\u300D\u5339\u914D\uFF08/events\u3001/auth/login \u7B49\uFF09\u3002\n */\nexport function routePath(ctxPath: string): string {\n if (ctxPath === '/api') return '/'\n if (ctxPath.startsWith('/api/')) return ctxPath.slice(4)\n return ctxPath\n}\n"
624
+ "content": "/**\n * \u670D\u52A1\u7AEF\u65E5\u671F\u5DE5\u5177\uFF08\u79FB\u690D\u81EA cf-backend src/lib/date.ts\uFF09\u3002\n * \u4E0E\u5C0F\u7A0B\u5E8F utils/date.js \u7B97\u6CD5\u5BF9\u9F50\uFF1A\u5929\u6570\u6309\u300C\u5317\u4EAC\u65F6\u95F4\uFF08UTC+8\uFF09\u81EA\u7136\u65E5\u300D\u8BA1\u7B97\u3002\n */\nimport type { EventRow } from './store'\n\nconst WEEK = ['\u5468\u65E5', '\u5468\u4E00', '\u5468\u4E8C', '\u5468\u4E09', '\u5468\u56DB', '\u5468\u4E94', '\u5468\u516D']\n\n/** \u5317\u4EAC\u65F6\u95F4\u4ECA\u5929 YYYY-MM-DD\uFF08\u81EA\u7136\u65E5\uFF0C\u4E0D\u4EE5 UTC \u65E5\u671F\u4E3A\u51C6\uFF09 */\nexport function todayStr(): string {\n const now = new Date(Date.now() + 8 * 3600_000)\n const y = now.getUTCFullYear()\n const m = now.getUTCMonth() + 1\n const d = now.getUTCDate()\n return fmt(y, m, d)\n}\n\nfunction fmt(y: number, m: number, d: number): string {\n return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`\n}\n\nexport function isValidDate(s: unknown): s is string {\n if (typeof s !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return false\n const [y, m, d] = s.split('-').map(Number)\n if (m < 1 || m > 12 || d < 1 || d > 31) return false\n const dt = new Date(y, m - 1, d)\n return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d\n}\n\n/** \u6309\u5317\u4EAC\u65F6\u95F4\u81EA\u7136\u65E5\u5DEE\uFF08\u6B63\u6570\u672A\u6765 / \u8D1F\u6570\u8FC7\u53BB\uFF1Bcountup \u53D6\u7EDD\u5BF9\u503C\uFF09 */\nexport function calcDays(\n targetDate: string,\n direction: 'countdown' | 'countup' = 'countdown'\n): number {\n if (!isValidDate(targetDate)) return 0\n const target = new Date(`${targetDate}T00:00:00+08:00`).getTime()\n const now = new Date()\n const nowBeijing = new Date(now.getTime() + 8 * 3600_000)\n const todayStart = new Date(\n Date.UTC(nowBeijing.getUTCFullYear(), nowBeijing.getUTCMonth(), nowBeijing.getUTCDate()) -\n 8 * 3600_000\n ).getTime()\n const diff = Math.round((target - todayStart) / 86400000)\n return direction === 'countup' ? Math.abs(diff) : diff\n}\n\nexport function weekdayOf(targetDate: string): string {\n if (!isValidDate(targetDate)) return ''\n const [y, m, d] = targetDate.split('-').map(Number)\n return WEEK[new Date(y, m - 1, d).getDay()]\n}\n\n/** \u670D\u52A1\u5668\u65F6\u95F4 ISO\uFF08UTC\uFF0C\u5B58\u50A8\u7528\uFF09 */\nexport function nowIso(): string {\n return new Date().toISOString()\n}\n\n/** \u4E8B\u4EF6\u884C \u2192 DTO\uFF08\u9644 days / weekday\uFF1Bis_pinned \u5E03\u5C14\u5316\uFF09\u3002EventRow \u7C7B\u578B\u5728 store.ts\uFF08FN-029 \u53BB\u91CD\uFF09\u3002 */\nexport interface EventDTO {\n id: string\n title: string\n target_date: string\n note: string\n category: string\n direction: 'countdown' | 'countup'\n is_pinned: boolean\n sort_order: number\n created_at: string\n updated_at: string\n days: number\n}\n\nexport function toDTO(row: EventRow): EventDTO {\n const direction = row.direction === 'countup' ? 'countup' : 'countdown'\n const dto: EventDTO = {\n id: row.id,\n title: row.title,\n target_date: row.target_date,\n note: row.note ?? '',\n category: row.category ?? 'other',\n direction,\n is_pinned: !!row.is_pinned,\n sort_order: row.sort_order ?? 0,\n created_at: row.created_at ?? '',\n updated_at: row.updated_at ?? '',\n days: 0,\n }\n dto.days = calcDays(row.target_date, direction)\n return dto\n}\n\n/** \u5217\u8868\u6392\u5E8F\uFF1A\u7F6E\u9876\u5728\u524D\uFF0C\u5176\u4F59\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\uFF08\u8FC7\u53BB\u7684\u5929\u6570\u6392\u5728\u6700\u540E\uFF09 */\nexport function sortEvents<T extends EventDTO>(events: T[]): T[] {\n return events.toSorted((a, b) => {\n if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1\n const da = a.days >= 0 ? a.days : Number.MAX_SAFE_INTEGER\n const db = b.days >= 0 ? b.days : Number.MAX_SAFE_INTEGER\n return da - db\n })\n}\n"
633
625
  },
634
626
  {
635
627
  "path": "functions/_shared/store.ts",
636
- "content": "/**\n * \u6570\u636E\u8BBF\u95EE\u5C42\uFF08\u79FB\u690D\u81EA cf-backend src/db/queries.ts + src/db/client.ts\uFF09\u3002\n *\n * \u8FC1\u79FB\u70B9\uFF1ACloudflare D1 \u7684 prepare().bind().run()/all() \u2192 adep `cloud.db` \u94FE\u5F0F\n * builder\uFF08\u7B49\u4EF7 SQLite \u5B50\u96C6\uFF1Bsim \u5F15\u64CE\u4E0E\u5E73\u53F0\u9879\u76EE\u5E93\u540C\u4E00\u5957\u5B9E\u73B0\uFF0C\u89C1\n * `packages/runtime/src/database/builder`\uFF09\u3002\u8868\u7ED3\u6784\u7531 functions/schema.sql \u58F0\u660E\uFF0C\n * \u90E8\u7F72\u524D\u7528 `adep db migrate` \u5EFA\u8868\uFF1B\u672C\u5730 dev\uFF08adep dev / vite \u63D2\u4EF6\uFF09\u7684 sim \u5F15\u64CE\n * \u8BBF\u95EE\u65F6\u4F1A\u81EA\u52A8\u5EFA\u8868\u3002\n */\nimport type { CloudDb, SqlValue } from '@adep/types'\n\n/** users \u8868\u884C\uFF08\u8BFB\u51FA\u7684\u884C\u5B57\u6BB5\u53EF\u7A7A\uFF0C\u5199\u65F6\u8865\u5168\uFF09 */\nexport interface UserRow {\n id: string\n device_id: string\n nickname: string | null\n avatar_url: string | null\n created_at: string | null\n updated_at: string | null\n}\n\n/** events \u8868\u884C\uFF08\u8BFB\u51FA\u7684\u884C\u5B57\u6BB5\u53EF\u7A7A\uFF0C\u5199\u65F6\u8865\u5168\uFF09 */\nexport interface EventRow {\n id: string\n user_id: string\n title: string\n target_date: string\n note: string | null\n category: string | null\n direction: 'countdown' | 'countup' | null\n is_pinned: number | boolean | null\n sort_order: number | null\n created_at: string | null\n updated_at: string | null\n}\n\n/** \u884C\u5BF9\u8C61 \u2192 builder \u53EF\u63A5\u53D7\u7684 SqlValue \u8BB0\u5F55\uFF08\u7ED3\u6784\u517C\u5BB9\u5373\u53EF\uFF0C\u5185\u90E8\u90FD\u662F\u57FA\u672C\u7C7B\u578B\uFF09\u3002 */\nfunction sql(row: Record<string, unknown>): Record<string, SqlValue> {\n return row as unknown as Record<string, SqlValue>\n}\n\n/** \u65B0\u5EFA\u7528\u6237\u884C\u7684\u5B8C\u6574\u5F62\u72B6\uFF08\u5199\u5165\u4E13\u7528\uFF09\u3002 */\nexport interface NewUserRow {\n id: string\n device_id: string\n nickname: string\n avatar_url: string\n created_at: string\n updated_at: string\n}\n\n/** \u65B0\u5EFA\u4E8B\u4EF6\u884C\u7684\u5B8C\u6574\u5F62\u72B6\uFF08\u5199\u5165\u4E13\u7528\uFF09\u3002 */\nexport interface NewEventRow {\n id: string\n user_id: string\n title: string\n target_date: string\n note: string\n category: string\n direction: 'countdown' | 'countup'\n is_pinned: 0 | 1\n sort_order: number\n created_at: string\n updated_at: string\n}\n\n// ---------- users ----------\n\nexport async function findUserByDeviceId(db: CloudDb, deviceId: string): Promise<UserRow | null> {\n const row = await db.table('users').where('device_id', deviceId).first()\n return (row as unknown as UserRow | undefined) ?? null\n}\n\nexport async function findUserById(db: CloudDb, id: string): Promise<UserRow | null> {\n const row = await db.table('users').where('id', id).first()\n return (row as unknown as UserRow | undefined) ?? null\n}\n\nexport async function createUser(db: CloudDb, row: NewUserRow): Promise<void> {\n await db.table('users').insert(sql(row as unknown as Record<string, unknown>))\n}\n\n// ---------- events ----------\n\nexport async function listEvents(db: CloudDb, userId: string): Promise<EventRow[]> {\n const rows = await db.table('events').where('user_id', userId).get()\n return (rows as unknown as EventRow[]) ?? []\n}\n\nexport async function findEventById(\n db: CloudDb,\n id: string,\n userId: string\n): Promise<EventRow | null> {\n const row = await db.table('events').where('id', id).where('user_id', userId).first()\n return (row as unknown as EventRow | undefined) ?? null\n}\n\nexport async function countEvents(db: CloudDb, userId: string): Promise<number> {\n // cloud.db \u7684 count() \u8FD4\u56DE number\uFF08builder \u5185\u90E8\u53D6 SELECT count(*) AS n \u7684 n\uFF09\n const n = await db.table('events').where('user_id', userId).count()\n return typeof n === 'number' ? n : Number(n ?? 0)\n}\n\nexport async function insertEvent(db: CloudDb, row: NewEventRow): Promise<void> {\n await db.table('events').insert(sql(row as unknown as Record<string, unknown>))\n}\n\nexport async function updateEvent(\n db: CloudDb,\n id: string,\n userId: string,\n patch: Partial<NewEventRow>\n): Promise<void> {\n await db\n .table('events')\n .where('id', id)\n .where('user_id', userId)\n .update(sql(patch as unknown as Record<string, unknown>))\n}\n\nexport async function deleteEvent(db: CloudDb, id: string, userId: string): Promise<void> {\n await db.table('events').where('id', id).where('user_id', userId).delete()\n}\n"
628
+ "content": "/**\n * \u6570\u636E\u8BBF\u95EE\u5C42\uFF08\u79FB\u690D\u81EA cf-backend src/db/queries.ts + src/db/client.ts\uFF09\u3002\n *\n * \u8FC1\u79FB\u70B9\uFF1ACloudflare D1 \u7684 prepare().bind().run()/all() \u2192 adep `cloud.db` \u94FE\u5F0F\n * builder\uFF08\u7B49\u4EF7 SQLite \u5B50\u96C6\uFF1Bsim \u5F15\u64CE\u4E0E\u5E73\u53F0\u9879\u76EE\u5E93\u540C\u4E00\u5957\u5B9E\u73B0\uFF0C\u89C1\n * `packages/runtime/src/database/builder`\uFF09\u3002\u8868\u7ED3\u6784\u7531 functions/schema.sql \u58F0\u660E\uFF0C\n * \u90E8\u7F72\u524D\u7528 `adep db migrate` \u5EFA\u8868\uFF1B\u672C\u5730 dev\uFF08adep dev / vite \u63D2\u4EF6\uFF09\u7684 sim \u5F15\u64CE\n * \u8BBF\u95EE\u65F6\u4F1A\u81EA\u52A8\u5EFA\u8868\u3002\n *\n * \u7C7B\u578B\u7EA6\u5B9A\uFF1A`cloud.db` \u7684\u884C\u5217\u7C7B\u578B\u7F16\u8BD1\u671F\u672A\u77E5\uFF08\u8FD0\u884C\u65F6\u5EFA\u8868\uFF0C\u89C1 @adep/types/cloud-db\uFF09\uFF0C\n * \u8FD9\u91CC\u7528 `firstAs<T>` / `rowsAs<T>` \u4E24\u4E2A\u5C0F\u52A9\u624B\u628A\u300C\u53CC\u91CD\u65AD\u8A00\u300D\u6536\u655B\u5230\u4E00\u5904\u2014\u2014\n * \u884C\u7ED3\u6784\u53D8\u5316\u65F6\u53EA\u9700\u6539\u8FD9\u91CC\u7684 T \u4E0E NewXxxRow\uFF0C\u4E0D\u518D\u6563\u843D 5 \u5904 `as unknown as`\u3002\n */\nimport type { CloudDb, Row, SqlValue } from '@adep/types'\n\n/** users \u8868\u884C\uFF08\u8BFB\u51FA\u7684\u884C\u5B57\u6BB5\u53EF\u7A7A\uFF0C\u5199\u65F6\u8865\u5168\uFF09 */\nexport interface UserRow {\n id: string\n device_id: string\n nickname: string | null\n avatar_url: string | null\n created_at: string | null\n updated_at: string | null\n}\n\n/** events \u8868\u884C\uFF08\u8BFB\u51FA\u7684\u884C\u5B57\u6BB5\u53EF\u7A7A\uFF0C\u5199\u65F6\u8865\u5168\uFF09\u2014\u2014date.ts \u7684 toDTO \u4E5F\u5F15\u7528\u8FD9\u4E00\u4EFD\uFF08FN-029 \u53BB\u91CD\uFF09\u3002 */\nexport interface EventRow {\n id: string\n user_id: string\n title: string\n target_date: string\n note: string | null\n category: string | null\n direction: 'countdown' | 'countup' | null\n is_pinned: number | boolean | null\n sort_order: number | null\n created_at: string | null\n updated_at: string | null\n}\n\n/** \u884C\u5BF9\u8C61 \u2192 builder \u53EF\u63A5\u53D7\u7684 SqlValue \u8BB0\u5F55\uFF08\u7ED3\u6784\u517C\u5BB9\u5373\u53EF\uFF0C\u5185\u90E8\u90FD\u662F\u57FA\u672C\u7C7B\u578B\uFF09\u3002 */\nfunction sql(row: Record<string, unknown>): Record<string, SqlValue> {\n return row as unknown as Record<string, SqlValue>\n}\n\n/** \u65AD\u8A00\u52A9\u624B\uFF08\u6536\u655B\u5904\uFF09\uFF1A\u9996\u884C \u2192 T | null\u3002 */\nfunction firstAs<T>(row: Row | null): T | null {\n return (row as unknown as T | undefined) ?? null\n}\n\n/** \u65AD\u8A00\u52A9\u624B\uFF08\u6536\u655B\u5904\uFF09\uFF1A\u884C\u6570\u7EC4 \u2192 T[]\u3002 */\nfunction rowsAs<T>(rows: Row[]): T[] {\n return rows as unknown as T[]\n}\n\n/** \u65B0\u5EFA\u7528\u6237\u884C\u7684\u5B8C\u6574\u5F62\u72B6\uFF08\u5199\u5165\u4E13\u7528\uFF09\u3002 */\nexport interface NewUserRow {\n id: string\n device_id: string\n nickname: string\n avatar_url: string\n created_at: string\n updated_at: string\n}\n\n/** \u65B0\u5EFA\u4E8B\u4EF6\u884C\u7684\u5B8C\u6574\u5F62\u72B6\uFF08\u5199\u5165\u4E13\u7528\uFF09\u3002 */\nexport interface NewEventRow {\n id: string\n user_id: string\n title: string\n target_date: string\n note: string\n category: string\n direction: 'countdown' | 'countup'\n is_pinned: 0 | 1\n sort_order: number\n created_at: string\n updated_at: string\n}\n\n// ---------- users ----------\n\nexport async function findUserByDeviceId(db: CloudDb, deviceId: string): Promise<UserRow | null> {\n return firstAs<UserRow>(await db.table('users').where('device_id', deviceId).first())\n}\n\nexport async function findUserById(db: CloudDb, id: string): Promise<UserRow | null> {\n return firstAs<UserRow>(await db.table('users').where('id', id).first())\n}\n\nexport async function createUser(db: CloudDb, row: NewUserRow): Promise<void> {\n await db.table('users').insert(sql(row as unknown as Record<string, unknown>))\n}\n\n// ---------- events ----------\n\nexport async function listEvents(db: CloudDb, userId: string): Promise<EventRow[]> {\n return rowsAs<EventRow>(await db.table('events').where('user_id', userId).get())\n}\n\nexport async function findEventById(\n db: CloudDb,\n id: string,\n userId: string\n): Promise<EventRow | null> {\n return firstAs<EventRow>(\n await db.table('events').where('id', id).where('user_id', userId).first()\n )\n}\n\nexport async function countEvents(db: CloudDb, userId: string): Promise<number> {\n // cloud.db \u7684 count() \u8FD4\u56DE number\uFF08builder \u5185\u90E8\u53D6 SELECT count(*) AS n \u7684 n\uFF09\n const n = await db.table('events').where('user_id', userId).count()\n return typeof n === 'number' ? n : Number(n ?? 0)\n}\n\nexport async function insertEvent(db: CloudDb, row: NewEventRow): Promise<void> {\n await db.table('events').insert(sql(row as unknown as Record<string, unknown>))\n}\n\nexport async function updateEvent(\n db: CloudDb,\n id: string,\n userId: string,\n patch: Partial<NewEventRow>\n): Promise<void> {\n await db\n .table('events')\n .where('id', id)\n .where('user_id', userId)\n .update(sql(patch as unknown as Record<string, unknown>))\n}\n\nexport async function deleteEvent(db: CloudDb, id: string, userId: string): Promise<void> {\n await db.table('events').where('id', id).where('user_id', userId).delete()\n}\n"
637
629
  },
638
630
  {
639
631
  "path": "functions/auth.ts",
640
- "content": "/**\n * \u4E91\u51FD\u6570\uFF1Aauth \u2014\u2014 \u767B\u5F55 / \u5F53\u524D\u7528\u6237\uFF08\u79FB\u690D\u81EA cf-backend src/routes/auth.ts\uFF09\u3002\n *\n * \u8DEF\u7531\uFF08\u5F52\u4E00\u5316\u540E\uFF0C\u524D\u7F00 /api \u7531 _shared/route.ts \u5265\u9664\uFF09\uFF1A\n * POST /auth/login { deviceId } \u2192 \u53D6/\u5EFA\u7528\u6237\uFF0C\u8FD4\u56DE { token, expires_in, user }\n * GET /auth/me \u2192 \u5F53\u524D\u7528\u6237 + \u4E8B\u4EF6\u8BA1\u6570\n *\n * \u9274\u6743\u53CC\u6765\u6E90\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u89C1 _shared/auth.ts\uFF09\uFF1AOIDC \u8EAB\u4EFD\uFF08`Authorization: Bearer`\n * OAuth token\uFF0C\u7F51\u5173\u89E3\u6790\u6210 ctx.user\uFF09\u4F18\u5148\uFF1B\u672A\u547D\u4E2D\u56DE\u9000 device_id \u51ED\u636E\u3002`/auth/me` \u7684\n * `auth_mode` \u5B57\u6BB5\u636E\u6B64\u533A\u5206\uFF1A'oidc' = \u5E73\u53F0\u4E09\u65B9\u767B\u5F55 / 'device' = \u672C\u5730\u8BBE\u5907\u8EAB\u4EFD\u3002\n */\nimport type { FunctionContext } from '@adep/types'\nimport { ok, fail, CODE } from './_shared/response'\nimport {\n findUserByDeviceId,\n findUserById,\n createUser,\n countEvents,\n type NewUserRow,\n} from './_shared/store'\nimport { requireUser, isEnvelope } from './_shared/auth'\nimport { routePath } from './_shared/route'\nimport { nowIso } from './_shared/date'\n\ninterface UserDTO {\n id: string\n nickname: string\n avatar_url: string\n created_at: string\n}\n\nfunction toUserDTO(row: {\n id: string\n nickname: string | null\n avatar_url: string | null\n created_at: string | null\n}): UserDTO {\n return {\n id: row.id,\n nickname: row.nickname ?? '\u672C\u5730\u7528\u6237',\n avatar_url: row.avatar_url ?? '',\n created_at: row.created_at ?? '',\n }\n}\n\nexport default async function handle(ctx: FunctionContext) {\n const db = ctx.cloud.db\n if (!db) return fail(CODE.SERVER_ERROR, '\u6570\u636E\u5E93\u672A\u5C31\u7EEA\uFF0C\u8BF7\u5148\u6267\u884C adep db start \u5E76\u5EFA\u8868')\n\n const path = routePath(ctx.path)\n const method = String(ctx.method ?? 'GET').toUpperCase()\n\n // POST /auth/login \u2014\u2014 \u8BBE\u5907\u8EAB\u4EFD\u6362\u767B\u5F55\u6001\uFF08\u7B49\u4EF7 wx.login \u2192 code2Session\uFF09\n if (path === '/auth/login' && method === 'POST') {\n const body = (ctx.body ?? {}) as Record<string, unknown>\n const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : ''\n if (!deviceId || deviceId.length < 8 || deviceId.length > 128) {\n return fail(CODE.BAD_PARAM, 'deviceId \u53C2\u6570\u4E0D\u5408\u6CD5')\n }\n\n try {\n let user = await findUserByDeviceId(db, deviceId)\n if (!user) {\n const fresh: NewUserRow = {\n id: crypto.randomUUID(),\n device_id: deviceId,\n nickname: '\u672C\u5730\u7528\u6237',\n avatar_url: '',\n created_at: nowIso(),\n updated_at: nowIso(),\n }\n await createUser(db, fresh)\n user = fresh\n }\n return ok({\n token: deviceId, // \u8BBE\u5907\u8EAB\u4EFD\u5373 Bearer \u51ED\u636E\uFF08\u7B49\u4EF7 API Key\uFF1B\u524D\u7AEF\u6301\u4E45\u5316\u4E8E localStorage\uFF09\n expires_in: 7 * 24 * 3600,\n user: toUserDTO(user),\n })\n } catch (err) {\n console.error('[auth/login]', err)\n return fail(CODE.SERVER_ERROR, '\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5')\n }\n }\n\n // GET /auth/me \u2014\u2014 \u5F53\u524D\u7528\u6237 + \u4E8B\u4EF6\u8BA1\u6570\n if (path === '/auth/me' && method === 'GET') {\n const authed = await requireUser(db, ctx)\n if (isEnvelope(authed)) return authed\n const { user } = authed\n const eventCount = await countEvents(db, user.id)\n const fresh = (await findUserById(db, user.id)) ?? user\n return ok({\n user: toUserDTO(fresh),\n event_count: eventCount,\n max_events: 200,\n // \u767B\u5F55\u5373\u670D\u52A1\uFF1A'oidc' = \u5E73\u53F0\u4E09\u65B9\u767B\u5F55\uFF08\u7F51\u5173\u89E3\u6790 Bearer \u2192 ctx.user\uFF09\uFF1B'device' = \u672C\u5730\u8BBE\u5907\u8EAB\u4EFD\u3002\n auth_mode: ctx.user !== null ? 'oidc' : 'device',\n })\n }\n\n return fail(CODE.NOT_FOUND, '\u63A5\u53E3\u4E0D\u5B58\u5728')\n}\n"
632
+ "content": "/**\n * \u4E91\u51FD\u6570\uFF1Aauth \u2014\u2014 \u767B\u5F55 / \u5F53\u524D\u7528\u6237\uFF08\u79FB\u690D\u81EA cf-backend src/routes/auth.ts\uFF09\u3002\n *\n * FN-029 \u8D77\u7528\u5B98\u65B9\u8DEF\u7531\u52A9\u624B\uFF08`@adep/runtime/route`\uFF09\u58F0\u660E\u5F0F\u6CE8\u518C\uFF1A\n * \u524D\u7F00\u5265\u79BB\u3001\u8DEF\u5F84\u53C2\u6570\u3001404/405\u3001\u9274\u6743\u4E2D\u95F4\u4EF6\u5168\u90E8\u7531\u8DEF\u7531\u5668\u627F\u62C5\uFF0C\u51FD\u6570\u4F53\u53EA\u5269\u4E1A\u52A1\u5206\u652F\u3002\n *\n * \u8DEF\u7531\uFF08\u5DF2\u5265 /api \u524D\u7F00\uFF09\uFF1A\n * POST /auth/login { deviceId } \u2192 \u53D6/\u5EFA\u7528\u6237\uFF0C\u8FD4\u56DE { token, expires_in, user }\n * GET /auth/me \u2192 \u5F53\u524D\u7528\u6237 + \u4E8B\u4EF6\u8BA1\u6570\n *\n * \u9274\u6743\u53CC\u6765\u6E90\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF0C\u89C1 _shared/auth.ts\uFF09\uFF1AOIDC \u8EAB\u4EFD\uFF08`Authorization: Bearer`\n * OAuth token\uFF0C\u7F51\u5173\u89E3\u6790\u6210 ctx.user\uFF09\u4F18\u5148\uFF1B\u672A\u547D\u4E2D\u56DE\u9000 device_id \u51ED\u636E\u3002`/auth/me` \u7684\n * `auth_mode` \u5B57\u6BB5\u636E\u6B64\u533A\u5206\uFF1A'oidc' = \u5E73\u53F0\u4E09\u65B9\u767B\u5F55 / 'device' = \u672C\u5730\u8BBE\u5907\u8EAB\u4EFD\u3002\n */\nimport type { FunctionContext } from '@adep/types'\nimport { createRouter } from '@adep/runtime/route'\nimport { ok, fail, CODE } from '@adep/runtime/response'\nimport {\n findUserByDeviceId,\n findUserById,\n createUser,\n countEvents,\n type NewUserRow,\n} from './_shared/store'\nimport { resolveUser, parseBearer } from './_shared/auth'\nimport { nowIso } from './_shared/date'\n\ninterface UserDTO {\n id: string\n nickname: string\n avatar_url: string\n created_at: string\n}\n\nfunction toUserDTO(row: {\n id: string\n nickname: string | null\n avatar_url: string | null\n created_at: string | null\n}): UserDTO {\n return {\n id: row.id,\n nickname: row.nickname ?? '\u672C\u5730\u7528\u6237',\n avatar_url: row.avatar_url ?? '',\n created_at: row.created_at ?? '',\n }\n}\n\nconst router = createRouter<FunctionContext>()\n\n// \u9274\u6743\u53E3\u5F84\uFF1Alogin \u662F\u300C\u6362\u51ED\u636E\u300D\u7AEF\u70B9\u3001me \u662F\u300C\u9A8C\u51ED\u636E\u300D\u7AEF\u70B9\u2014\u2014auth \u51FD\u6570\u4E0D\u6302\u5168\u5C40\u5B88\u536B\uFF0C\n// me \u5728 handler \u5185\u8D70 resolveUser\uFF08authGuard \u7684\u5B6A\u751F\u51FD\u6570\uFF09\uFF0C\u5931\u8D25\u8BED\u4E49\u4E0E\u5B88\u536B\u5B8C\u5168\u4E00\u81F4\u3002\nrouter.post('/auth/login', async (req) => {\n const ctx = req.raw\n const db = ctx.cloud.db\n const body = (ctx.body ?? {}) as Record<string, unknown>\n const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : ''\n if (!deviceId || deviceId.length < 8 || deviceId.length > 128) {\n return fail(CODE.BAD_PARAM, 'deviceId \u53C2\u6570\u4E0D\u5408\u6CD5')\n }\n\n try {\n let user = await findUserByDeviceId(db, deviceId)\n if (!user) {\n const fresh: NewUserRow = {\n id: crypto.randomUUID(),\n device_id: deviceId,\n nickname: '\u672C\u5730\u7528\u6237',\n avatar_url: '',\n created_at: nowIso(),\n updated_at: nowIso(),\n }\n await createUser(db, fresh)\n user = fresh\n }\n return ok({\n token: deviceId, // \u8BBE\u5907\u8EAB\u4EFD\u5373 Bearer \u51ED\u636E\uFF08\u7B49\u4EF7 API Key\uFF1B\u524D\u7AEF\u6301\u4E45\u5316\u4E8E localStorage\uFF09\n expires_in: 7 * 24 * 3600,\n user: toUserDTO(user),\n })\n } catch (err) {\n console.error('[auth/login]', err)\n return fail(CODE.SERVER_ERROR, '\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5')\n }\n})\n\nrouter.get('/auth/me', async (req) => {\n const ctx = req.raw\n const db = ctx.cloud.db\n const user = await resolveUser(db, ctx)\n if (user === null) {\n return fail(CODE.NO_AUTH, parseBearer(ctx) === '' ? '\u672A\u767B\u5F55' : '\u767B\u5F55\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55')\n }\n const eventCount = await countEvents(db, user.id)\n const fresh = (await findUserById(db, user.id)) ?? user\n return ok({\n user: toUserDTO(fresh),\n event_count: eventCount,\n max_events: 200,\n // \u767B\u5F55\u5373\u670D\u52A1\uFF1A'oidc' = \u5E73\u53F0\u4E09\u65B9\u767B\u5F55\uFF08\u7F51\u5173\u89E3\u6790 Bearer \u2192 ctx.user\uFF09\uFF1B'device' = \u672C\u5730\u8BBE\u5907\u8EAB\u4EFD\u3002\n auth_mode: ctx.user !== null ? 'oidc' : 'device',\n })\n})\n\nexport default async function handle(ctx: FunctionContext) {\n return router.handleAsEnvelope(ctx)\n}\n"
641
633
  },
642
634
  {
643
635
  "path": "functions/events.ts",
644
- "content": "/**\n * \u4E91\u51FD\u6570\uFF1Aevents \u2014\u2014 \u4E8B\u4EF6 CRUD\uFF08\u79FB\u690D\u81EA cf-backend src/routes/events.ts\uFF09\u3002\n *\n * \u8DEF\u7531\uFF08\u5F52\u4E00\u5316\u540E\uFF09\uFF1A\n * GET /events?category=xxx \u2192 { events }\uFF08\u7F6E\u9876\u4F18\u5148\uFF0C\u5176\u4F59\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\uFF09\n * POST /events \u2192 \u521B\u5EFA\uFF08\u4E0A\u9650 200 \u4E2A\uFF09\n * GET /events/:id \u2192 { event }\n * PUT /events/:id \u2192 \u5C40\u90E8\u66F4\u65B0\n * DELETE /events/:id \u2192 \u5220\u9664\n * \u9274\u6743\uFF1AOIDC \u8EAB\u4EFD\uFF08\u7F51\u5173\u89E3\u6790 Bearer \u2192 ctx.user\uFF09\u4F18\u5148\uFF0C\u672A\u547D\u4E2D\u56DE\u9000 device_id\uFF08\u89C1 _shared/auth.ts\uFF09\u3002\n */\nimport type { FunctionContext } from '@adep/types'\nimport { ok, fail, CODE } from './_shared/response'\nimport { requireUser, isEnvelope } from './_shared/auth'\nimport { routePath } from './_shared/route'\nimport { isValidDate, toDTO, sortEvents, nowIso, type EventRow } from './_shared/date'\nimport {\n findEventById,\n insertEvent,\n updateEvent,\n deleteEvent,\n listEvents,\n countEvents,\n type NewEventRow,\n} from './_shared/store'\n\nconst CATEGORIES = new Set(['other', 'birthday', 'anniversary', 'exam', 'holiday'])\nconst MAX_EVENTS = 200\n\nfunction normalizeBody<T>(ctx: FunctionContext): T {\n if (ctx.body && typeof ctx.body === 'object') return ctx.body as T\n return {} as T\n}\n\nexport default async function handle(ctx: FunctionContext) {\n const db = ctx.cloud.db\n if (!db) return fail(CODE.SERVER_ERROR, '\u6570\u636E\u5E93\u672A\u5C31\u7EEA\uFF0C\u8BF7\u5148\u6267\u884C adep db start \u5E76\u5EFA\u8868')\n\n const authed = await requireUser(db, ctx)\n if (isEnvelope(authed)) return authed\n const { user } = authed\n\n const path = routePath(ctx.path)\n const method = String(ctx.method ?? 'GET').toUpperCase()\n\n // GET /events\n if (path === '/events' && method === 'GET') {\n const category = ctx.query?.category\n let events = (await listEvents(db, user.id)).map((row) => toDTO(row as unknown as EventRow))\n if (typeof category === 'string' && category && CATEGORIES.has(category)) {\n events = events.filter((e) => e.category === category)\n }\n return ok({ events: sortEvents(events) })\n }\n\n // POST /events\n if (path === '/events' && method === 'POST') {\n const body = normalizeBody<{\n title?: unknown\n target_date?: unknown\n note?: unknown\n category?: unknown\n direction?: unknown\n is_pinned?: unknown\n }>(ctx)\n\n const title = typeof body.title === 'string' ? body.title.trim() : ''\n const target_date = typeof body.target_date === 'string' ? body.target_date : ''\n const note = typeof body.note === 'string' ? body.note.trim() : ''\n const category = typeof body.category === 'string' ? body.category : 'other'\n const direction = body.direction === 'countup' ? 'countup' : 'countdown'\n const is_pinned = !!body.is_pinned\n\n if (!title) return fail(CODE.BAD_PARAM, '\u8BF7\u586B\u5199\u4E8B\u4EF6\u540D\u79F0')\n if (title.length > 50) return fail(CODE.BAD_PARAM, '\u540D\u79F0\u4E0D\u80FD\u8D85\u8FC7 50 \u4E2A\u5B57')\n if (!isValidDate(target_date)) return fail(CODE.BAD_PARAM, '\u8BF7\u9009\u62E9\u76EE\u6807\u65E5\u671F')\n if (note.length > 200) return fail(CODE.BAD_PARAM, '\u5907\u6CE8\u4E0D\u80FD\u8D85\u8FC7 200 \u4E2A\u5B57')\n if (!CATEGORIES.has(category)) return fail(CODE.BAD_PARAM, '\u5206\u7C7B\u4E0D\u5408\u6CD5')\n\n const count = await countEvents(db, user.id)\n if (count >= MAX_EVENTS) return fail(CODE.LIMIT_EXCEEDED, `\u6700\u591A\u521B\u5EFA ${MAX_EVENTS} \u4E2A\u4E8B\u4EF6`)\n\n const now = nowIso()\n const event: NewEventRow = {\n id: crypto.randomUUID(),\n user_id: user.id,\n title,\n target_date,\n note,\n category,\n direction,\n is_pinned: is_pinned ? 1 : 0,\n sort_order: 0,\n created_at: now,\n updated_at: now,\n }\n await insertEvent(db, event)\n return ok({ event: toDTO(event) }, '\u521B\u5EFA\u6210\u529F')\n }\n\n // GET /events/:id\n const matchId = /^\\/events\\/([^/]+)$/.exec(path)\n if (matchId && method === 'GET') {\n const event = await findEventById(db, matchId[1], user.id)\n if (!event) return fail(CODE.NOT_FOUND, '\u4E8B\u4EF6\u4E0D\u5B58\u5728')\n return ok({ event: toDTO(event as unknown as EventRow) })\n }\n\n // PUT /events/:id\n if (matchId && method === 'PUT') {\n const event = await findEventById(db, matchId[1], user.id)\n if (!event) return fail(CODE.NOT_FOUND, '\u4E8B\u4EF6\u4E0D\u5B58\u5728')\n\n const body = normalizeBody<{\n title?: unknown\n target_date?: unknown\n note?: unknown\n category?: unknown\n direction?: unknown\n is_pinned?: unknown\n }>(ctx)\n\n const patch: Partial<NewEventRow> = {}\n const { title, target_date, note, category, direction, is_pinned } = body\n if (title !== undefined) {\n if (typeof title !== 'string' || !title.trim()) return fail(CODE.BAD_PARAM, '\u8BF7\u586B\u5199\u4E8B\u4EF6\u540D\u79F0')\n if (title.trim().length > 50) return fail(CODE.BAD_PARAM, '\u540D\u79F0\u4E0D\u80FD\u8D85\u8FC7 50 \u4E2A\u5B57')\n patch.title = title.trim()\n }\n if (target_date !== undefined) {\n if (typeof target_date !== 'string' || !isValidDate(target_date)) {\n return fail(CODE.BAD_PARAM, '\u8BF7\u9009\u62E9\u76EE\u6807\u65E5\u671F')\n }\n patch.target_date = target_date\n }\n if (note !== undefined) {\n if (typeof note !== 'string') return fail(CODE.BAD_PARAM, '\u5907\u6CE8\u683C\u5F0F\u4E0D\u5408\u6CD5')\n if (note.trim().length > 200) return fail(CODE.BAD_PARAM, '\u5907\u6CE8\u4E0D\u80FD\u8D85\u8FC7 200 \u4E2A\u5B57')\n patch.note = note.trim()\n }\n if (category !== undefined) {\n if (typeof category !== 'string' || !CATEGORIES.has(category)) {\n return fail(CODE.BAD_PARAM, '\u5206\u7C7B\u4E0D\u5408\u6CD5')\n }\n patch.category = category\n }\n if (direction !== undefined) {\n if (direction !== 'countdown' && direction !== 'countup') {\n return fail(CODE.BAD_PARAM, '\u8BA1\u65F6\u65B9\u5F0F\u4E0D\u5408\u6CD5')\n }\n patch.direction = direction\n }\n if (is_pinned !== undefined) {\n patch.is_pinned = is_pinned ? 1 : 0\n }\n\n patch.updated_at = nowIso()\n await updateEvent(db, event.id, user.id, patch)\n const fresh = await findEventById(db, event.id, user.id)\n return ok({ event: toDTO((fresh ?? event) as unknown as EventRow) }, '\u5DF2\u4FDD\u5B58')\n }\n\n // DELETE /events/:id\n if (matchId && method === 'DELETE') {\n const event = await findEventById(db, matchId[1], user.id)\n if (!event) return fail(CODE.NOT_FOUND, '\u4E8B\u4EF6\u4E0D\u5B58\u5728')\n await deleteEvent(db, event.id, user.id)\n return ok({ id: event.id }, '\u5DF2\u5220\u9664')\n }\n\n return fail(CODE.NOT_FOUND, '\u63A5\u53E3\u4E0D\u5B58\u5728')\n}\n"
636
+ "content": "/**\n * \u4E91\u51FD\u6570\uFF1Aevents \u2014\u2014 \u4E8B\u4EF6 CRUD\uFF08\u79FB\u690D\u81EA cf-backend src/routes/events.ts\uFF09\u3002\n *\n * FN-029 \u8D77\u7528\u5B98\u65B9\u8DEF\u7531\u52A9\u624B\uFF08`@adep/runtime/route`\uFF09+ \u9274\u6743\u4E2D\u95F4\u4EF6\uFF08`_shared/auth.ts`\n * \u7684 authGuard\uFF09\uFF1A\u524D\u7F00\u5265\u79BB\u3001`:id` \u8DEF\u5F84\u53C2\u6570\u3001404/405\u3001\u6BCF\u7AEF\u70B9\u7684\u9274\u6743\u6536\u7A84\u6837\u677F\u5168\u90E8\n * \u6D88\u9664\u2014\u2014handler \u91CC `req.state.user` \u5373\u5DF2\u767B\u5F55\u7528\u6237\uFF0C\u51FD\u6570\u4F53\u53EA\u5269\u7EAF\u4E1A\u52A1\u903B\u8F91\u3002\n *\n * \u8DEF\u7531\uFF08\u5DF2\u5265 /api \u524D\u7F00\uFF09\uFF1A\n * GET /events?category=xxx \u2192 { events }\uFF08\u7F6E\u9876\u4F18\u5148\uFF0C\u5176\u4F59\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\uFF09\n * POST /events \u2192 \u521B\u5EFA\uFF08\u4E0A\u9650 200 \u4E2A\uFF09\n * GET /events/:id \u2192 { event }\n * PUT /events/:id \u2192 \u5C40\u90E8\u66F4\u65B0\n * DELETE /events/:id \u2192 \u5220\u9664\n */\nimport type { FunctionContext } from '@adep/types'\nimport { createRouter } from '@adep/runtime/route'\nimport { ok, fail, CODE } from '@adep/runtime/response'\nimport { authGuard } from './_shared/auth'\nimport type { UserRow } from './_shared/store'\nimport { isValidDate, toDTO, sortEvents, nowIso } from './_shared/date'\nimport type { EventRow } from './_shared/store'\nimport {\n findEventById,\n insertEvent,\n updateEvent,\n deleteEvent,\n listEvents,\n countEvents,\n type NewEventRow,\n} from './_shared/store'\n\nconst CATEGORIES = new Set(['other', 'birthday', 'anniversary', 'exam', 'holiday'])\nconst MAX_EVENTS = 200\n\n/** \u4ECE\u4E2D\u95F4\u4EF6\u80CC\u5305\u53D6\u5DF2\u767B\u5F55\u7528\u6237\uFF08authGuard \u4FDD\u8BC1\u5B58\u5728\uFF1B\u7C7B\u578B\u65AD\u8A00\u53EA\u5728\u8FD9\u4E00\u5904\uFF09\u3002 */\nfunction userOf(req: { state: Record<string, unknown> }): UserRow {\n return req.state.user as UserRow\n}\n\nconst router = createRouter<FunctionContext>()\n\n// \u5168\u51FD\u6570\u9274\u6743\uFF1A\u6CE8\u518C\u4E4B\u540E\u7684\u6240\u6709\u7AEF\u70B9\u90FD\u8981\u6C42\u767B\u5F55\uFF08401 \u77ED\u8DEF\uFF0C\u4E0D\u8FDB handler\uFF09\u3002\nrouter.use(authGuard())\n\n// GET /events \u2014\u2014 \u5217\u8868\uFF08\u53EF\u6309\u5206\u7C7B\u8FC7\u6EE4\uFF1B\u7F6E\u9876\u5728\u524D\uFF0C\u5176\u4F59\u6309\u5269\u4F59\u5929\u6570\u5347\u5E8F\uFF09\nrouter.get('/events', async (req) => {\n const user = userOf(req)\n const ctx = req.raw\n const db = ctx.cloud.db\n const category = ctx.query?.category\n let events = (await listEvents(db, user.id)).map((row) => toDTO(row as EventRow))\n if (typeof category === 'string' && category && CATEGORIES.has(category)) {\n events = events.filter((e) => e.category === category)\n }\n return ok({ events: sortEvents(events) })\n})\n\n// POST /events \u2014\u2014 \u521B\u5EFA\nrouter.post('/events', async (req) => {\n const user = userOf(req)\n const ctx = req.raw\n const db = ctx.cloud.db\n const body = (ctx.body ?? {}) as Record<string, unknown>\n\n const title = typeof body.title === 'string' ? body.title.trim() : ''\n const target_date = typeof body.target_date === 'string' ? body.target_date : ''\n const note = typeof body.note === 'string' ? body.note.trim() : ''\n const category = typeof body.category === 'string' ? body.category : 'other'\n const direction = body.direction === 'countup' ? 'countup' : 'countdown'\n const is_pinned = !!body.is_pinned\n\n if (!title) return fail(CODE.BAD_PARAM, '\u8BF7\u586B\u5199\u4E8B\u4EF6\u540D\u79F0')\n if (title.length > 50) return fail(CODE.BAD_PARAM, '\u540D\u79F0\u4E0D\u80FD\u8D85\u8FC7 50 \u4E2A\u5B57')\n if (!isValidDate(target_date)) return fail(CODE.BAD_PARAM, '\u8BF7\u9009\u62E9\u76EE\u6807\u65E5\u671F')\n if (note.length > 200) return fail(CODE.BAD_PARAM, '\u5907\u6CE8\u4E0D\u80FD\u8D85\u8FC7 200 \u4E2A\u5B57')\n if (!CATEGORIES.has(category)) return fail(CODE.BAD_PARAM, '\u5206\u7C7B\u4E0D\u5408\u6CD5')\n\n const count = await countEvents(db, user.id)\n if (count >= MAX_EVENTS) return fail(CODE.LIMIT_EXCEEDED, `\u6700\u591A\u521B\u5EFA ${MAX_EVENTS} \u4E2A\u4E8B\u4EF6`)\n\n const now = nowIso()\n const event: NewEventRow = {\n id: crypto.randomUUID(),\n user_id: user.id,\n title,\n target_date,\n note,\n category,\n direction,\n is_pinned: is_pinned ? 1 : 0,\n sort_order: 0,\n created_at: now,\n updated_at: now,\n }\n await insertEvent(db, event)\n return ok({ event: toDTO(event) }, '\u521B\u5EFA\u6210\u529F')\n})\n\n// GET /events/:id \u2014\u2014 \u8BE6\u60C5\nrouter.get('/events/:id', async (req) => {\n const user = userOf(req)\n const db = req.raw.cloud.db\n const event = await findEventById(db, req.params.id ?? '', user.id)\n if (!event) return fail(CODE.NOT_FOUND, '\u4E8B\u4EF6\u4E0D\u5B58\u5728')\n return ok({ event: toDTO(event as EventRow) })\n})\n\n// PUT /events/:id \u2014\u2014 \u5C40\u90E8\u66F4\u65B0\uFF08\u53EA\u6536\u51FA\u73B0\u7684\u5B57\u6BB5\uFF09\nrouter.put('/events/:id', async (req) => {\n const user = userOf(req)\n const ctx = req.raw\n const db = ctx.cloud.db\n const event = await findEventById(db, req.params.id ?? '', user.id)\n if (!event) return fail(CODE.NOT_FOUND, '\u4E8B\u4EF6\u4E0D\u5B58\u5728')\n\n const body = (ctx.body ?? {}) as Record<string, unknown>\n const patch: Partial<NewEventRow> = {}\n const { title, target_date, note, category, direction, is_pinned } = body\n if (title !== undefined) {\n if (typeof title !== 'string' || !title.trim()) return fail(CODE.BAD_PARAM, '\u8BF7\u586B\u5199\u4E8B\u4EF6\u540D\u79F0')\n if (title.trim().length > 50) return fail(CODE.BAD_PARAM, '\u540D\u79F0\u4E0D\u80FD\u8D85\u8FC7 50 \u4E2A\u5B57')\n patch.title = title.trim()\n }\n if (target_date !== undefined) {\n if (typeof target_date !== 'string' || !isValidDate(target_date)) {\n return fail(CODE.BAD_PARAM, '\u8BF7\u9009\u62E9\u76EE\u6807\u65E5\u671F')\n }\n patch.target_date = target_date\n }\n if (note !== undefined) {\n if (typeof note !== 'string') return fail(CODE.BAD_PARAM, '\u5907\u6CE8\u683C\u5F0F\u4E0D\u5408\u6CD5')\n if (note.trim().length > 200) return fail(CODE.BAD_PARAM, '\u5907\u6CE8\u4E0D\u80FD\u8D85\u8FC7 200 \u4E2A\u5B57')\n patch.note = note.trim()\n }\n if (category !== undefined) {\n if (typeof category !== 'string' || !CATEGORIES.has(category)) {\n return fail(CODE.BAD_PARAM, '\u5206\u7C7B\u4E0D\u5408\u6CD5')\n }\n patch.category = category\n }\n if (direction !== undefined) {\n if (direction !== 'countdown' && direction !== 'countup') {\n return fail(CODE.BAD_PARAM, '\u8BA1\u65F6\u65B9\u5F0F\u4E0D\u5408\u6CD5')\n }\n patch.direction = direction\n }\n if (is_pinned !== undefined) {\n patch.is_pinned = is_pinned ? 1 : 0\n }\n\n patch.updated_at = nowIso()\n await updateEvent(db, event.id, user.id, patch)\n const fresh = await findEventById(db, event.id, user.id)\n return ok({ event: toDTO((fresh ?? event) as EventRow) }, '\u5DF2\u4FDD\u5B58')\n})\n\n// DELETE /events/:id \u2014\u2014 \u5220\u9664\nrouter.delete('/events/:id', async (req) => {\n const user = userOf(req)\n const db = req.raw.cloud.db\n const event = await findEventById(db, req.params.id ?? '', user.id)\n if (!event) return fail(CODE.NOT_FOUND, '\u4E8B\u4EF6\u4E0D\u5B58\u5728')\n await deleteEvent(db, event.id, user.id)\n return ok({ id: event.id }, '\u5DF2\u5220\u9664')\n})\n\nexport default async function handle(ctx: FunctionContext) {\n return router.handleAsEnvelope(ctx)\n}\n"
645
637
  },
646
638
  {
647
639
  "path": "web/src/App.vue",
@@ -697,7 +689,7 @@ declare module '*.vue' {
697
689
  },
698
690
  {
699
691
  "path": "web/src/lib/events.test.ts",
700
- "content": "/**\n * countdown web \u4E8B\u4EF6\u6570\u636E\u5C42\u5355\u6D4B\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF09\uFF1A\u5DF2\u767B\u5F55 \u2192 \u4E91\u7AEF /api/events\uFF1B\n * \u672A\u767B\u5F55 \u2192 localStorage\u3002\u8986\u76D6\u5217\u8868 / \u589E\u5220\u6539 / \u767B\u5F55\u540E\u540C\u6B65\uFF08syncLocalEvents\uFF09\u3002\n * \u8FD0\u884C\uFF1Aapps/countdown \u6839\u4E0B `npm run test`\uFF08app \u7EA7 vitest \u6536\u96C6 web \u7684\u6D4B\u8BD5\u6587\u4EF6\uFF09\u3002\n */\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { request } from './request'\nimport type { CountdownEvent } from './date'\n\n// ---- mock \u4F9D\u8D56\uFF1Aoidc\uFF08\u767B\u5F55\u6001\u5F00\u5173\uFF09\u4E0E request\uFF08\u4E91\u7AEF\u54CD\u5E94\uFF09----\n\nconst mocks = vi.hoisted(() => ({\n loggedIn: false as boolean,\n}))\n\nvi.mock('./oidc', () => ({\n hasSession: () => mocks.loggedIn,\n getRequestToken: async () => (mocks.loggedIn ? 'tok' : ''),\n forceRefreshToken: async () => null,\n hasSessionExport: undefined,\n loadSession: () => null,\n}))\n\nvi.mock('./request', () => ({\n request: vi.fn(),\n}))\n\nimport { listEvents, createEvent, updateEvent, deleteEvent, syncLocalEvents, isLoggedIn } from './events'\n\nconst LOCAL_KEY = 'countdown_local_events'\n\n// ---- node \u73AF\u5883\u65E0 localStorage\uFF1A\u88C5\u4E00\u4E2A\u5185\u5B58\u6869\uFF08data \u5C42\u53EA\u5728\u8C03\u7528\u65F6\u8BFB\u5199\uFF09----\n\nclass MemoryStorage {\n private store = new Map<string, string>()\n getItem(key: string): string | null {\n return this.store.get(key) ?? null\n }\n setItem(key: string, value: string): void {\n this.store.set(key, value)\n }\n removeItem(key: string): void {\n this.store.delete(key)\n }\n clear(): void {\n this.store.clear()\n }\n get length(): number {\n return this.store.size\n }\n key(index: number): string | null {\n return [...this.store.keys()][index] ?? null\n }\n}\n\n// \u53EA\u5728\u672A\u5B9A\u4E49\u65F6\u88C5\uFF08\u6D4F\u89C8\u5668\u73AF\u5883 / jsdom \u76F4\u63A5\u590D\u7528\uFF09\u3002\nconst g = globalThis as Record<string, unknown>\nif (typeof g.localStorage !== 'object' || g.localStorage === null) {\n Object.defineProperty(g, 'localStorage', { value: new MemoryStorage(), writable: true })\n}\n\n/** \u8BFB localStorage \u91CC\u7684\u672C\u5730\u4E8B\u4EF6\u3002 */\nfunction readStored(): CountdownEvent[] {\n return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') as CountdownEvent[]\n}\n\n/** \u624B\u5DE5\u6784\u9020\u4E00\u6761\u4E91\u7AEF\u4E8B\u4EF6\u3002 */\nfunction cloudEvent(overrides: Partial<CountdownEvent> = {}): CountdownEvent {\n return {\n id: 'c-1',\n title: '\u4E91\u7AEF\u4E8B\u4EF6',\n target_date: '2026-12-31',\n note: '',\n category: 'other',\n direction: 'countdown',\n is_pinned: false,\n sort_order: 0,\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n days: 0,\n ...overrides,\n }\n}\n\n/** \u9020\u4E00\u6761\u5DF2\u5B58\u5728\u7684\u672C\u5730\u4E8B\u4EF6\uFF08\u76F4\u63A5\u843D localStorage\uFF09\u3002 */\nfunction seedLocal(event: CountdownEvent): void {\n localStorage.setItem(LOCAL_KEY, JSON.stringify([event]))\n}\n\nconst localEvent = (): CountdownEvent =>\n cloudEvent({\n id: 'local-1',\n title: '\u672C\u5730\u4E8B\u4EF6',\n target_date: '2026-10-01',\n created_at: '2026-02-01T00:00:00.000Z',\n updated_at: '2026-02-01T00:00:00.000Z',\n })\n\nbeforeEach(() => {\n mocks.loggedIn = false\n localStorage.clear()\n vi.clearAllMocks()\n})\n\nafterEach(() => {\n localStorage.clear()\n})\n\ndescribe('\u767B\u5F55\u6001\u5224\u5B9A', () => {\n it('isLoggedIn \u8DDF\u968F OIDC \u4F1A\u8BDD', () => {\n mocks.loggedIn = false\n expect(isLoggedIn()).toBe(false)\n mocks.loggedIn = true\n expect(isLoggedIn()).toBe(true)\n })\n})\n\ndescribe('\u672A\u767B\u5F55\uFF1A\u5168\u90E8\u843D localStorage', () => {\n it('listEvents \u8BFB\u672C\u5730\uFF08\u4E0D\u8BF7\u6C42\u4E91\u7AEF\uFF09', async () => {\n seedLocal(localEvent())\n const list = await listEvents()\n expect(list).toHaveLength(1)\n expect(list[0]?.id).toBe('local-1')\n expect(request).not.toHaveBeenCalled()\n })\n\n it('createEvent \u843D\u672C\u5730\u5E76\u8865\u5168\u5B57\u6BB5', async () => {\n const created = await createEvent({ title: '\u65B0\u4E8B\u4EF6', target_date: '2026-11-11' })\n expect(created.id).toBeTypeOf('string')\n expect(readStored()).toHaveLength(1)\n expect(readStored()[0]?.title).toBe('\u65B0\u4E8B\u4EF6')\n expect(request).not.toHaveBeenCalled()\n })\n\n it('updateEvent / deleteEvent \u64CD\u4F5C\u672C\u5730\u884C', async () => {\n seedLocal(localEvent())\n const updated = await updateEvent('local-1', { title: '\u6539\u540D' })\n expect(updated.title).toBe('\u6539\u540D')\n expect(readStored()[0]?.title).toBe('\u6539\u540D')\n\n await deleteEvent('local-1')\n expect(readStored()).toHaveLength(0)\n expect(request).not.toHaveBeenCalled()\n })\n})\n\ndescribe('\u5DF2\u767B\u5F55\uFF1A\u8D70\u4E91\u7AEF', () => {\n beforeEach(() => {\n mocks.loggedIn = true\n })\n\n it('listEvents \u62C9\u4E91\u7AEF\u5E76\u8FD4\u56DE events \u6570\u7EC4', async () => {\n vi.mocked(request).mockResolvedValue({ events: [cloudEvent()] })\n const list = await listEvents()\n expect(list).toHaveLength(1)\n expect(list[0]?.id).toBe('c-1')\n expect(request).toHaveBeenCalledWith('/api/events')\n })\n\n it('\u4E91\u7AEF\u62C9\u53D6\u5931\u8D25 \u2192 \u56DE\u9000\u672C\u5730\u7F13\u5B58\uFF08\u4E0D\u629B\u9519\uFF09', async () => {\n seedLocal(localEvent())\n vi.mocked(request).mockRejectedValue(new Error('\u7F51\u7EDC\u5F02\u5E38'))\n const list = await listEvents()\n expect(list).toHaveLength(1)\n expect(list[0]?.id).toBe('local-1')\n })\n\n it('createEvent \u8C03\u4E91\u7AEF POST', async () => {\n vi.mocked(request).mockResolvedValue({ event: cloudEvent() })\n await createEvent({ title: 'x', target_date: '2026-12-01' })\n expect(request).toHaveBeenCalledWith(\n '/api/events',\n expect.objectContaining({ method: 'POST', data: { title: 'x', target_date: '2026-12-01' } })\n )\n })\n\n it('deleteEvent \u8C03\u4E91\u7AEF DELETE', async () => {\n vi.mocked(request).mockResolvedValue(null)\n await deleteEvent('c-1')\n expect(request).toHaveBeenCalledWith('/api/events/c-1', expect.objectContaining({ method: 'DELETE' }))\n })\n})\n\ndescribe('syncLocalEvents\uFF1A\u767B\u5F55\u540E\u628A\u672C\u5730\u5E76\u5165\u4E91\u7AEF', () => {\n it('\u672A\u767B\u5F55 \u2192 0 \u4E14\u4E0D\u8BF7\u6C42', async () => {\n mocks.loggedIn = false\n seedLocal(localEvent())\n expect(await syncLocalEvents()).toBe(0)\n expect(request).not.toHaveBeenCalled()\n })\n\n it('\u5DF2\u767B\u5F55\uFF1A\u672C\u5730\u5168\u90E8\u4E0A\u4F20 \u2192 \u8FD4\u56DE\u6761\u6570\u5E76\u6E05\u7A7A\u672C\u5730', async () => {\n mocks.loggedIn = true\n seedLocal(localEvent())\n\n // \u4E91\u7AEF\u4E3A\u7A7A\uFF1A\u6BCF\u4E2A\u672C\u5730\u4E8B\u4EF6\u90FD\u65B0\u589E\uFF08request \u4F9D\u6B21\u5E94\u7B54\u5217\u8868\u4E0E\u6BCF\u6B21 POST\uFF09\u3002\n vi.mocked(request)\n .mockResolvedValueOnce({ events: [] })\n .mockResolvedValueOnce({ event: cloudEvent({ id: 'synced-1' }) })\n\n const n = await syncLocalEvents()\n expect(n).toBe(1)\n expect(readStored()).toHaveLength(0)\n expect(request).toHaveBeenCalledWith('/api/events', { retry: false })\n expect(request).toHaveBeenCalledWith(\n '/api/events',\n expect.objectContaining({ method: 'POST', data: expect.objectContaining({ title: '\u672C\u5730\u4E8B\u4EF6' }) })\n )\n })\n\n it('\u5DF2\u767B\u5F55\uFF1A\u4E0E\u4E91\u7AEF\u91CD\u590D\u7684\u4E8B\u4EF6\u8DF3\u8FC7\u4E0A\u4F20\uFF08\u6309 title+target_date \u53BB\u91CD\uFF09', async () => {\n mocks.loggedIn = true\n const dup = cloudEvent({ id: 'local-dup', title: '\u672C\u5730\u4E8B\u4EF6', target_date: '2026-10-01' })\n seedLocal(dup)\n\n // \u4E91\u7AEF\u5DF2\u6709\u540C title+target_date \u7684\u4E8B\u4EF6\u3002\n vi.mocked(request).mockResolvedValue({\n events: [cloudEvent({ id: 'c-dup', title: '\u672C\u5730\u4E8B\u4EF6', target_date: '2026-10-01' })],\n })\n\n const n = await syncLocalEvents()\n expect(n).toBe(0)\n // \u4ECD\u89C6\u4E3A\u5168\u90E8\u5E76\u5165 \u2192 \u6E05\u7A7A\u672C\u5730\uFF08\u91CD\u590D\u7684\u4E91\u7AEF\u5DF2\u5B58\u5728\uFF09\u3002\n expect(readStored()).toHaveLength(0)\n })\n})\n"
692
+ "content": "/**\n * countdown web \u4E8B\u4EF6\u6570\u636E\u5C42\u5355\u6D4B\uFF08\u767B\u5F55\u5373\u670D\u52A1\uFF09\uFF1A\u5DF2\u767B\u5F55 \u2192 \u4E91\u7AEF /api/events\uFF1B\n * \u672A\u767B\u5F55 \u2192 localStorage\u3002\u8986\u76D6\u5217\u8868 / \u589E\u5220\u6539 / \u767B\u5F55\u540E\u540C\u6B65\uFF08syncLocalEvents\uFF09\u3002\n * \u8FD0\u884C\uFF1A\u9879\u76EE\u6839\u4E0B `npm run test`\uFF08app \u7EA7 vitest \u6536\u96C6 web \u7684\u6D4B\u8BD5\u6587\u4EF6\uFF09\u3002\n */\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { request } from './request'\nimport type { CountdownEvent } from './date'\n\n// ---- mock \u4F9D\u8D56\uFF1Aoidc\uFF08\u767B\u5F55\u6001\u5F00\u5173\uFF09\u4E0E request\uFF08\u4E91\u7AEF\u54CD\u5E94\uFF09----\n\nconst mocks = vi.hoisted(() => ({\n loggedIn: false as boolean,\n}))\n\nvi.mock('./oidc', () => ({\n hasSession: () => mocks.loggedIn,\n getRequestToken: async () => (mocks.loggedIn ? 'tok' : ''),\n forceRefreshToken: async () => null,\n hasSessionExport: undefined,\n loadSession: () => null,\n}))\n\nvi.mock('./request', () => ({\n request: vi.fn(),\n}))\n\nimport { listEvents, createEvent, updateEvent, deleteEvent, syncLocalEvents, isLoggedIn } from './events'\n\nconst LOCAL_KEY = 'countdown_local_events'\n\n// ---- node \u73AF\u5883\u65E0 localStorage\uFF1A\u88C5\u4E00\u4E2A\u5185\u5B58\u6869\uFF08data \u5C42\u53EA\u5728\u8C03\u7528\u65F6\u8BFB\u5199\uFF09----\n\nclass MemoryStorage {\n private store = new Map<string, string>()\n getItem(key: string): string | null {\n return this.store.get(key) ?? null\n }\n setItem(key: string, value: string): void {\n this.store.set(key, value)\n }\n removeItem(key: string): void {\n this.store.delete(key)\n }\n clear(): void {\n this.store.clear()\n }\n get length(): number {\n return this.store.size\n }\n key(index: number): string | null {\n return [...this.store.keys()][index] ?? null\n }\n}\n\n// \u53EA\u5728\u672A\u5B9A\u4E49\u65F6\u88C5\uFF08\u6D4F\u89C8\u5668\u73AF\u5883 / jsdom \u76F4\u63A5\u590D\u7528\uFF09\u3002\nconst g = globalThis as Record<string, unknown>\nif (typeof g.localStorage !== 'object' || g.localStorage === null) {\n Object.defineProperty(g, 'localStorage', { value: new MemoryStorage(), writable: true })\n}\n\n/** \u8BFB localStorage \u91CC\u7684\u672C\u5730\u4E8B\u4EF6\u3002 */\nfunction readStored(): CountdownEvent[] {\n return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') as CountdownEvent[]\n}\n\n/** \u624B\u5DE5\u6784\u9020\u4E00\u6761\u4E91\u7AEF\u4E8B\u4EF6\u3002 */\nfunction cloudEvent(overrides: Partial<CountdownEvent> = {}): CountdownEvent {\n return {\n id: 'c-1',\n title: '\u4E91\u7AEF\u4E8B\u4EF6',\n target_date: '2026-12-31',\n note: '',\n category: 'other',\n direction: 'countdown',\n is_pinned: false,\n sort_order: 0,\n created_at: '2026-01-01T00:00:00.000Z',\n updated_at: '2026-01-01T00:00:00.000Z',\n days: 0,\n ...overrides,\n }\n}\n\n/** \u9020\u4E00\u6761\u5DF2\u5B58\u5728\u7684\u672C\u5730\u4E8B\u4EF6\uFF08\u76F4\u63A5\u843D localStorage\uFF09\u3002 */\nfunction seedLocal(event: CountdownEvent): void {\n localStorage.setItem(LOCAL_KEY, JSON.stringify([event]))\n}\n\nconst localEvent = (): CountdownEvent =>\n cloudEvent({\n id: 'local-1',\n title: '\u672C\u5730\u4E8B\u4EF6',\n target_date: '2026-10-01',\n created_at: '2026-02-01T00:00:00.000Z',\n updated_at: '2026-02-01T00:00:00.000Z',\n })\n\nbeforeEach(() => {\n mocks.loggedIn = false\n localStorage.clear()\n vi.clearAllMocks()\n})\n\nafterEach(() => {\n localStorage.clear()\n})\n\ndescribe('\u767B\u5F55\u6001\u5224\u5B9A', () => {\n it('isLoggedIn \u8DDF\u968F OIDC \u4F1A\u8BDD', () => {\n mocks.loggedIn = false\n expect(isLoggedIn()).toBe(false)\n mocks.loggedIn = true\n expect(isLoggedIn()).toBe(true)\n })\n})\n\ndescribe('\u672A\u767B\u5F55\uFF1A\u5168\u90E8\u843D localStorage', () => {\n it('listEvents \u8BFB\u672C\u5730\uFF08\u4E0D\u8BF7\u6C42\u4E91\u7AEF\uFF09', async () => {\n seedLocal(localEvent())\n const list = await listEvents()\n expect(list).toHaveLength(1)\n expect(list[0]?.id).toBe('local-1')\n expect(request).not.toHaveBeenCalled()\n })\n\n it('createEvent \u843D\u672C\u5730\u5E76\u8865\u5168\u5B57\u6BB5', async () => {\n const created = await createEvent({ title: '\u65B0\u4E8B\u4EF6', target_date: '2026-11-11' })\n expect(created.id).toBeTypeOf('string')\n expect(readStored()).toHaveLength(1)\n expect(readStored()[0]?.title).toBe('\u65B0\u4E8B\u4EF6')\n expect(request).not.toHaveBeenCalled()\n })\n\n it('updateEvent / deleteEvent \u64CD\u4F5C\u672C\u5730\u884C', async () => {\n seedLocal(localEvent())\n const updated = await updateEvent('local-1', { title: '\u6539\u540D' })\n expect(updated.title).toBe('\u6539\u540D')\n expect(readStored()[0]?.title).toBe('\u6539\u540D')\n\n await deleteEvent('local-1')\n expect(readStored()).toHaveLength(0)\n expect(request).not.toHaveBeenCalled()\n })\n})\n\ndescribe('\u5DF2\u767B\u5F55\uFF1A\u8D70\u4E91\u7AEF', () => {\n beforeEach(() => {\n mocks.loggedIn = true\n })\n\n it('listEvents \u62C9\u4E91\u7AEF\u5E76\u8FD4\u56DE events \u6570\u7EC4', async () => {\n vi.mocked(request).mockResolvedValue({ events: [cloudEvent()] })\n const list = await listEvents()\n expect(list).toHaveLength(1)\n expect(list[0]?.id).toBe('c-1')\n expect(request).toHaveBeenCalledWith('/api/events')\n })\n\n it('\u4E91\u7AEF\u62C9\u53D6\u5931\u8D25 \u2192 \u56DE\u9000\u672C\u5730\u7F13\u5B58\uFF08\u4E0D\u629B\u9519\uFF09', async () => {\n seedLocal(localEvent())\n vi.mocked(request).mockRejectedValue(new Error('\u7F51\u7EDC\u5F02\u5E38'))\n const list = await listEvents()\n expect(list).toHaveLength(1)\n expect(list[0]?.id).toBe('local-1')\n })\n\n it('createEvent \u8C03\u4E91\u7AEF POST', async () => {\n vi.mocked(request).mockResolvedValue({ event: cloudEvent() })\n await createEvent({ title: 'x', target_date: '2026-12-01' })\n expect(request).toHaveBeenCalledWith(\n '/api/events',\n expect.objectContaining({ method: 'POST', data: { title: 'x', target_date: '2026-12-01' } })\n )\n })\n\n it('deleteEvent \u8C03\u4E91\u7AEF DELETE', async () => {\n vi.mocked(request).mockResolvedValue(null)\n await deleteEvent('c-1')\n expect(request).toHaveBeenCalledWith('/api/events/c-1', expect.objectContaining({ method: 'DELETE' }))\n })\n})\n\ndescribe('syncLocalEvents\uFF1A\u767B\u5F55\u540E\u628A\u672C\u5730\u5E76\u5165\u4E91\u7AEF', () => {\n it('\u672A\u767B\u5F55 \u2192 0 \u4E14\u4E0D\u8BF7\u6C42', async () => {\n mocks.loggedIn = false\n seedLocal(localEvent())\n expect(await syncLocalEvents()).toBe(0)\n expect(request).not.toHaveBeenCalled()\n })\n\n it('\u5DF2\u767B\u5F55\uFF1A\u672C\u5730\u5168\u90E8\u4E0A\u4F20 \u2192 \u8FD4\u56DE\u6761\u6570\u5E76\u6E05\u7A7A\u672C\u5730', async () => {\n mocks.loggedIn = true\n seedLocal(localEvent())\n\n // \u4E91\u7AEF\u4E3A\u7A7A\uFF1A\u6BCF\u4E2A\u672C\u5730\u4E8B\u4EF6\u90FD\u65B0\u589E\uFF08request \u4F9D\u6B21\u5E94\u7B54\u5217\u8868\u4E0E\u6BCF\u6B21 POST\uFF09\u3002\n vi.mocked(request)\n .mockResolvedValueOnce({ events: [] })\n .mockResolvedValueOnce({ event: cloudEvent({ id: 'synced-1' }) })\n\n const n = await syncLocalEvents()\n expect(n).toBe(1)\n expect(readStored()).toHaveLength(0)\n expect(request).toHaveBeenCalledWith('/api/events', { retry: false })\n expect(request).toHaveBeenCalledWith(\n '/api/events',\n expect.objectContaining({ method: 'POST', data: expect.objectContaining({ title: '\u672C\u5730\u4E8B\u4EF6' }) })\n )\n })\n\n it('\u5DF2\u767B\u5F55\uFF1A\u4E0E\u4E91\u7AEF\u91CD\u590D\u7684\u4E8B\u4EF6\u8DF3\u8FC7\u4E0A\u4F20\uFF08\u6309 title+target_date \u53BB\u91CD\uFF09', async () => {\n mocks.loggedIn = true\n const dup = cloudEvent({ id: 'local-dup', title: '\u672C\u5730\u4E8B\u4EF6', target_date: '2026-10-01' })\n seedLocal(dup)\n\n // \u4E91\u7AEF\u5DF2\u6709\u540C title+target_date \u7684\u4E8B\u4EF6\u3002\n vi.mocked(request).mockResolvedValue({\n events: [cloudEvent({ id: 'c-dup', title: '\u672C\u5730\u4E8B\u4EF6', target_date: '2026-10-01' })],\n })\n\n const n = await syncLocalEvents()\n expect(n).toBe(0)\n // \u4ECD\u89C6\u4E3A\u5168\u90E8\u5E76\u5165 \u2192 \u6E05\u7A7A\u672C\u5730\uFF08\u91CD\u590D\u7684\u4E91\u7AEF\u5DF2\u5B58\u5728\uFF09\u3002\n expect(readStored()).toHaveLength(0)\n })\n})\n"
701
693
  },
702
694
  {
703
695
  "path": "web/src/lib/events.ts",
@@ -3297,6 +3289,57 @@ function asCfBindingsConfig(value, errors) {
3297
3289
  if (r2 !== null && r2 !== void 0) bindings.r2 = r2;
3298
3290
  return bindings;
3299
3291
  }
3292
+ function asTriggersConfig(value, errors) {
3293
+ if (!Array.isArray(value)) {
3294
+ errors.push(`triggers \u5FC5\u987B\u662F\u6570\u7EC4\uFF0C\u6536\u5230 ${typeNameOf(value)}`);
3295
+ return null;
3296
+ }
3297
+ const out = [];
3298
+ value.forEach((item, i) => {
3299
+ const idx = `triggers[${i}]`;
3300
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
3301
+ errors.push(`${idx} \u5FC5\u987B\u662F\u5BF9\u8C61\uFF0C\u6536\u5230 ${typeNameOf(item)}`);
3302
+ return;
3303
+ }
3304
+ const rec = item;
3305
+ if (typeof rec.function !== "string" || rec.function.length === 0) {
3306
+ errors.push(`${idx}.function \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF0C\u6536\u5230 ${typeNameOf(rec.function)}`);
3307
+ return;
3308
+ }
3309
+ if (rec.type !== "cron" && rec.type !== "storage.upload") {
3310
+ errors.push(`${idx}.type \u5FC5\u987B\u662F 'cron' \u6216 'storage.upload'\uFF0C\u6536\u5230 ${typeNameOf(rec.type)}`);
3311
+ return;
3312
+ }
3313
+ const entry = { function: rec.function, type: rec.type };
3314
+ if (rec.cron !== void 0) {
3315
+ if (typeof rec.cron !== "string") {
3316
+ errors.push(`${idx}.cron \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF0C\u6536\u5230 ${typeNameOf(rec.cron)}`);
3317
+ } else if (rec.type === "cron" && rec.cron.trim().length === 0) {
3318
+ errors.push(`${idx}.cron \u4E0D\u80FD\u4E3A\u7A7A\uFF08type='cron' \u5FC5\u987B\u7ED9\u51FA 5 \u5B57\u6BB5 cron \u8868\u8FBE\u5F0F\uFF09`);
3319
+ } else {
3320
+ entry.cron = rec.cron;
3321
+ }
3322
+ } else if (rec.type === "cron") {
3323
+ errors.push(`${idx}.cron \u7F3A\u5931\uFF1Atype='cron' \u5FC5\u987B\u7ED9\u51FA 5 \u5B57\u6BB5 cron \u8868\u8FBE\u5F0F`);
3324
+ }
3325
+ if (rec.prefix !== void 0) {
3326
+ if (typeof rec.prefix !== "string") {
3327
+ errors.push(`${idx}.prefix \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF0C\u6536\u5230 ${typeNameOf(rec.prefix)}`);
3328
+ } else {
3329
+ entry.prefix = rec.prefix;
3330
+ }
3331
+ }
3332
+ if (rec.enabled !== void 0) {
3333
+ if (typeof rec.enabled !== "boolean") {
3334
+ errors.push(`${idx}.enabled \u5FC5\u987B\u662F\u5E03\u5C14\u503C\uFF0C\u6536\u5230 ${typeNameOf(rec.enabled)}`);
3335
+ } else {
3336
+ entry.enabled = rec.enabled;
3337
+ }
3338
+ }
3339
+ out.push(entry);
3340
+ });
3341
+ return out;
3342
+ }
3300
3343
  function parseAdepConfig(raw) {
3301
3344
  if (raw === null || raw === void 0) {
3302
3345
  return { ok: true, data: { ...DEFAULT_ADEP_CONFIG } };
@@ -3361,6 +3404,10 @@ function parseAdepConfig(raw) {
3361
3404
  const bindings = asCfBindingsConfig(input.cfBindings, errors);
3362
3405
  if (bindings !== null) data.cfBindings = bindings;
3363
3406
  }
3407
+ if (input.triggers !== void 0) {
3408
+ const triggers = asTriggersConfig(input.triggers, errors);
3409
+ if (triggers !== null) data.triggers = triggers;
3410
+ }
3364
3411
  if (errors.length > 0) return { ok: false, errors };
3365
3412
  return { ok: true, data };
3366
3413
  }
@@ -3369,13 +3416,30 @@ function renderTsValue(value) {
3369
3416
  if (typeof value === "string") return `'${value}'`;
3370
3417
  if (typeof value === "boolean") return value ? "true" : "false";
3371
3418
  if (typeof value === "number") return String(value);
3372
- if (Array.isArray(value)) return `[${value.map(renderTsValue).join(", ")}]`;
3419
+ if (Array.isArray(value)) {
3420
+ if (value.every((item) => item === null || ["string", "boolean", "number"].includes(typeof item))) {
3421
+ return `[${value.map(renderTsValue).join(", ")}]`;
3422
+ }
3423
+ return `[${value.map((item) => renderTsValue(item)).join(", ")}]`;
3424
+ }
3373
3425
  if (typeof value === "object") {
3374
- const entries = Object.entries(value).map(([k, v]) => `${k}: ${renderTsValue(v)}`).join(", ");
3426
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).map(([k, v]) => `${k}: ${renderTsValue(v)}`).join(", ");
3375
3427
  return `{ ${entries} }`;
3376
3428
  }
3377
3429
  return String(value);
3378
3430
  }
3431
+ function renderTriggersBlock(triggers) {
3432
+ const lines = triggers.map((trigger) => {
3433
+ const parts = [`function: '${trigger.function}'`, `type: '${trigger.type}'`];
3434
+ if (trigger.cron !== void 0) parts.push(`cron: '${trigger.cron}'`);
3435
+ if (trigger.prefix !== void 0) parts.push(`prefix: '${trigger.prefix}'`);
3436
+ if (trigger.enabled !== void 0) parts.push(`enabled: ${trigger.enabled ? "true" : "false"}`);
3437
+ return ` { ${parts.join(", ")} },`;
3438
+ });
3439
+ return ` triggers: [
3440
+ ${lines.join("\n")}
3441
+ ],`;
3442
+ }
3379
3443
  function renderAdepConfig(config2, options = {}) {
3380
3444
  const functionsDir = config2.functionsDir ?? DEFAULT_ADEP_CONFIG.functionsDir;
3381
3445
  const functionsPrefix = config2.functions_prefix ?? DEFAULT_ADEP_CONFIG.functions_prefix;
@@ -3399,6 +3463,9 @@ function renderAdepConfig(config2, options = {}) {
3399
3463
  if (config2.cfBindings !== void 0) {
3400
3464
  lines.push(` cfBindings: ${renderTsValue(config2.cfBindings)},`);
3401
3465
  }
3466
+ if (config2.triggers !== void 0 && config2.triggers.length > 0) {
3467
+ lines.push(renderTriggersBlock(config2.triggers));
3468
+ }
3402
3469
  if (options.extraFields !== void 0) {
3403
3470
  for (const [key, value] of Object.entries(options.extraFields)) {
3404
3471
  lines.push(` ${key}: ${renderTsValue(value)},`);
@@ -3676,17 +3743,20 @@ var init_shared_assets = __esm({
3676
3743
  "@adep/cli": cliVersion,
3677
3744
  vue: "^3.5.42",
3678
3745
  "vue-router": "^5.0.0",
3679
- // OIDC 登录(web/src/lib/oidc.ts 运行时 import createAuthClient,CLI-018 同步 apps/countdown 后补齐)。
3746
+ // OIDC 登录(web/src/lib/oidc.ts 运行时 import createAuthClient)。
3680
3747
  "@adep/auth-client": "^0.2.2"
3681
3748
  },
3682
3749
  devDependencies: {
3683
3750
  "@adep/types": "^0.2.0",
3751
+ // 官方路由/响应包(FN-029):函数用 @adep/runtime/route + response(沙箱内置白名单,
3752
+ // 运行期零安装);devDependencies 仅为模板 _shared 单测提供类型与实现解析。
3753
+ "@adep/runtime": "^0.3.0",
3684
3754
  "@vitejs/plugin-vue": "^6.0.0",
3685
3755
  "esbuild-wasm": "^0.25.12",
3686
3756
  typescript: "5.9.3",
3687
3757
  vite: "^7.0.0",
3688
3758
  // vitest 用 ^3.2.4:vitest 4.x 的 packument 触发 npm 10.9 arborist edgesOut 崩溃(官方源同样),
3689
- // 与 apps/countdown 保持一致。
3759
+ // 与 countdown 模板保持一致。
3690
3760
  vitest: "^3.2.4"
3691
3761
  },
3692
3762
  engines: {
@@ -4110,6 +4180,17 @@ var init_prompt = __esm({
4110
4180
  }
4111
4181
  });
4112
4182
 
4183
+ // packages/runtime/src/shared/shared-source.ts
4184
+ var FUNCTION_SHARED_DIR, SHARED_SOURCE_ROOT, FUNCTION_SHARED_MAX_TOTAL_BYTES;
4185
+ var init_shared_source = __esm({
4186
+ "packages/runtime/src/shared/shared-source.ts"() {
4187
+ "use strict";
4188
+ FUNCTION_SHARED_DIR = "_shared";
4189
+ SHARED_SOURCE_ROOT = `functions/${FUNCTION_SHARED_DIR}/`;
4190
+ FUNCTION_SHARED_MAX_TOTAL_BYTES = 256 * 1024;
4191
+ }
4192
+ });
4193
+
4113
4194
  // shared/deploy-bundle/manifest.ts
4114
4195
  var CURRENT_SCHEMA_VERSION, BundleContentKind, ALLOWED_CONTENT_KINDS;
4115
4196
  var init_manifest = __esm({
@@ -19885,13 +19966,15 @@ function resolveDraftEntry(name, files) {
19885
19966
  }
19886
19967
  return null;
19887
19968
  }
19888
- var MAX_TOTAL_SOURCE_BYTES, ENTRY_FILE, ENTRY_SOURCE_EXTS, FnError;
19969
+ var MAX_TOTAL_SOURCE_BYTES, ENTRY_FILE, ENTRY_SOURCE_EXTS, FUNCTION_SHARED_DIR2, FnError;
19889
19970
  var init_domain = __esm({
19890
19971
  "packages/runtime/src/functions/domain.ts"() {
19891
19972
  "use strict";
19973
+ init_shared_source();
19892
19974
  MAX_TOTAL_SOURCE_BYTES = 256 * 1024;
19893
19975
  ENTRY_FILE = "index.ts";
19894
19976
  ENTRY_SOURCE_EXTS = [".ts", ".js", ".mjs", ".cjs"];
19977
+ FUNCTION_SHARED_DIR2 = `${FUNCTION_SHARED_DIR}/`;
19895
19978
  FnError = class extends Error {
19896
19979
  status;
19897
19980
  code;
@@ -23091,7 +23174,7 @@ var init_invoke = __esm({
23091
23174
  });
23092
23175
 
23093
23176
  // shared/builtin-deps.ts
23094
- var CF_COMPAT_SPECIFIER, STATE_OBJECTS_SPECIFIER, CRYPTO_SPECIFIER, ROUTE_SPECIFIER, BUILTIN_DEPS, BUILTIN_DEPS_CSV;
23177
+ var CF_COMPAT_SPECIFIER, STATE_OBJECTS_SPECIFIER, CRYPTO_SPECIFIER, ROUTE_SPECIFIER, RESPONSE_SPECIFIER, BUILTIN_DEPS, BUILTIN_DEPS_CSV;
23095
23178
  var init_builtin_deps = __esm({
23096
23179
  "shared/builtin-deps.ts"() {
23097
23180
  "use strict";
@@ -23099,11 +23182,13 @@ var init_builtin_deps = __esm({
23099
23182
  STATE_OBJECTS_SPECIFIER = "@adep/runtime/state-objects";
23100
23183
  CRYPTO_SPECIFIER = "@adep/crypto";
23101
23184
  ROUTE_SPECIFIER = "@adep/runtime/route";
23185
+ RESPONSE_SPECIFIER = "@adep/runtime/response";
23102
23186
  BUILTIN_DEPS = [
23103
23187
  CF_COMPAT_SPECIFIER,
23104
23188
  STATE_OBJECTS_SPECIFIER,
23105
23189
  CRYPTO_SPECIFIER,
23106
23190
  ROUTE_SPECIFIER,
23191
+ RESPONSE_SPECIFIER,
23107
23192
  "axios",
23108
23193
  "lodash",
23109
23194
  "dayjs",
@@ -23123,7 +23208,8 @@ var init_builtin_deps2 = __esm({
23123
23208
  CF_COMPAT_SPECIFIER,
23124
23209
  STATE_OBJECTS_SPECIFIER,
23125
23210
  CRYPTO_SPECIFIER,
23126
- ROUTE_SPECIFIER
23211
+ ROUTE_SPECIFIER,
23212
+ RESPONSE_SPECIFIER
23127
23213
  ];
23128
23214
  }
23129
23215
  });
@@ -24351,30 +24437,20 @@ var init_worker_entry = __esm({
24351
24437
  function mergeSharedSource(files, shared) {
24352
24438
  const merged = { ...files };
24353
24439
  for (const file2 of shared) {
24354
- if (!file2.path.startsWith(SHARED_SOURCE_ROOT)) continue;
24355
- const rel = file2.path.slice(SHARED_SOURCE_ROOT.length);
24440
+ if (!file2.path.startsWith(SHARED_SOURCE_ROOT2)) continue;
24441
+ const rel = file2.path.slice(SHARED_SOURCE_ROOT2.length);
24356
24442
  if (rel === "") continue;
24357
- merged[`${FUNCTION_SHARED_DIR}${rel}`] = file2.content;
24443
+ merged[`${FUNCTION_SHARED_DIR3}${rel}`] = file2.content;
24358
24444
  }
24359
24445
  return merged;
24360
24446
  }
24361
- var SHARED_SOURCE_ROOT, FUNCTION_SHARED_DIR;
24447
+ var SHARED_SOURCE_ROOT2, FUNCTION_SHARED_DIR3;
24362
24448
  var init_function_source = __esm({
24363
24449
  "packages/runtime/src/shared/function-source.ts"() {
24364
24450
  "use strict";
24365
- SHARED_SOURCE_ROOT = "functions/_shared/";
24366
- FUNCTION_SHARED_DIR = "_shared/";
24367
- }
24368
- });
24369
-
24370
- // shared/shared-source.ts
24371
- var FUNCTION_SHARED_DIR2, SHARED_SOURCE_ROOT2, FUNCTION_SHARED_MAX_TOTAL_BYTES;
24372
- var init_shared_source = __esm({
24373
- "shared/shared-source.ts"() {
24374
- "use strict";
24375
- FUNCTION_SHARED_DIR2 = "_shared";
24376
- SHARED_SOURCE_ROOT2 = `functions/${FUNCTION_SHARED_DIR2}/`;
24377
- FUNCTION_SHARED_MAX_TOTAL_BYTES = 256 * 1024;
24451
+ init_shared_source();
24452
+ SHARED_SOURCE_ROOT2 = SHARED_SOURCE_ROOT;
24453
+ FUNCTION_SHARED_DIR3 = `${FUNCTION_SHARED_DIR}/`;
24378
24454
  }
24379
24455
  });
24380
24456
 
@@ -24435,9 +24511,9 @@ async function collectFunctions(dir) {
24435
24511
  function mergeSharedIntoFunctions(files) {
24436
24512
  const shared = [];
24437
24513
  for (const [path, content] of Object.entries(files)) {
24438
- if (!path.startsWith(`${FUNCTION_SHARED_DIR2}/`)) continue;
24514
+ if (!path.startsWith(`${FUNCTION_SHARED_DIR}/`)) continue;
24439
24515
  shared.push({
24440
- path: `${SHARED_SOURCE_ROOT2}${path.slice(FUNCTION_SHARED_DIR2.length + 1)}`,
24516
+ path: `${SHARED_SOURCE_ROOT}${path.slice(FUNCTION_SHARED_DIR.length + 1)}`,
24441
24517
  content
24442
24518
  });
24443
24519
  }
@@ -25171,7 +25247,7 @@ function collectFunctionNames(manifest) {
25171
25247
  if (rest.length === 0) continue;
25172
25248
  const slash = rest.indexOf("/");
25173
25249
  const name = slash === -1 ? rest.replace(/\.[^./]+$/, "") : rest.slice(0, slash);
25174
- if (name.length === 0 || name === "_shared") continue;
25250
+ if (name.length === 0 || name === FUNCTION_SHARED_DIR) continue;
25175
25251
  names.add(name);
25176
25252
  }
25177
25253
  return [...names].toSorted((left, right) => left.localeCompare(right));
@@ -25626,6 +25702,7 @@ var SITE_PREFIX, SHELL_SITE_DIR, SHELL_OUT_DIR, REPORT_FILE, TOP_ASSETS_LIMIT, D
25626
25702
  var init_app_build = __esm({
25627
25703
  "packages/cli/src/app-build.ts"() {
25628
25704
  "use strict";
25705
+ init_shared_source();
25629
25706
  init_validate();
25630
25707
  init_zip();
25631
25708
  init_app_config();
@@ -26165,7 +26242,7 @@ async function collectFunctionFiles(functionsDir) {
26165
26242
  (entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")
26166
26243
  ).map((entry) => basename4(entry.name, ".ts")).toSorted();
26167
26244
  const dirNames = dirents.filter(
26168
- (entry) => entry.isDirectory() && entry.name !== SHARED_DIR_NAME && !IGNORED_DIRS.has(entry.name)
26245
+ (entry) => entry.isDirectory() && entry.name !== FUNCTION_SHARED_DIR && !IGNORED_DIRS.has(entry.name)
26169
26246
  ).map((entry) => entry.name).toSorted();
26170
26247
  const conflicting = flatNames.filter((name) => dirNames.includes(name));
26171
26248
  if (conflicting.length > 0) throw new AmbiguousFunctionError(functionsDir, conflicting);
@@ -26207,7 +26284,7 @@ function sha256Hex(content) {
26207
26284
  return createHash4("sha256").update(content).digest("hex");
26208
26285
  }
26209
26286
  async function collectShared(functionsDir) {
26210
- const sharedDir = join20(functionsDir, "_shared");
26287
+ const sharedDir = join20(functionsDir, FUNCTION_SHARED_DIR);
26211
26288
  const files = {};
26212
26289
  const walk = async (dir) => {
26213
26290
  let entries;
@@ -26225,7 +26302,7 @@ async function collectShared(functionsDir) {
26225
26302
  if (!isRuntimeSource2(entry.name)) continue;
26226
26303
  const full = join20(dir, entry.name);
26227
26304
  const rel = relative5(sharedDir, full).split(sep).join("/");
26228
- files[`${SHARED_SOURCE_ROOT3}${rel}`] = await readFile16(full, "utf8");
26305
+ files[`${SHARED_SOURCE_ROOT}${rel}`] = await readFile16(full, "utf8");
26229
26306
  }
26230
26307
  };
26231
26308
  await walk(sharedDir);
@@ -26334,7 +26411,7 @@ async function deploy(paths, options) {
26334
26411
  functions: deployed
26335
26412
  };
26336
26413
  }
26337
- var SHARED_SOURCE_ROOT3, AmbiguousFunctionError, IGNORED_DIRS, SHARED_DIR_NAME;
26414
+ var AmbiguousFunctionError, IGNORED_DIRS;
26338
26415
  var init_deploy = __esm({
26339
26416
  "packages/cli/src/deploy.ts"() {
26340
26417
  "use strict";
@@ -26343,7 +26420,8 @@ var init_deploy = __esm({
26343
26420
  init_client();
26344
26421
  init_dev();
26345
26422
  init_net();
26346
- SHARED_SOURCE_ROOT3 = "functions/_shared/";
26423
+ init_shared_source();
26424
+ init_shared_source();
26347
26425
  AmbiguousFunctionError = class extends CliError {
26348
26426
  constructor(functionsDir, names) {
26349
26427
  super(
@@ -26355,7 +26433,6 @@ var init_deploy = __esm({
26355
26433
  }
26356
26434
  };
26357
26435
  IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
26358
- SHARED_DIR_NAME = "_shared";
26359
26436
  }
26360
26437
  });
26361
26438
 
package/dist/vite.js CHANGED
@@ -92,10 +92,16 @@ import { resolve } from "node:path";
92
92
  // packages/runtime/src/functions/deps/manifest.ts
93
93
  import { createHash } from "node:crypto";
94
94
 
95
+ // packages/runtime/src/shared/shared-source.ts
96
+ var FUNCTION_SHARED_DIR = "_shared";
97
+ var SHARED_SOURCE_ROOT = `functions/${FUNCTION_SHARED_DIR}/`;
98
+ var FUNCTION_SHARED_MAX_TOTAL_BYTES = 256 * 1024;
99
+
95
100
  // packages/runtime/src/functions/domain.ts
96
101
  var MAX_TOTAL_SOURCE_BYTES = 256 * 1024;
97
102
  var ENTRY_FILE = "index.ts";
98
103
  var ENTRY_SOURCE_EXTS = [".ts", ".js", ".mjs", ".cjs"];
104
+ var FUNCTION_SHARED_DIR2 = `${FUNCTION_SHARED_DIR}/`;
99
105
  function flatEntryCandidates(name) {
100
106
  return ENTRY_SOURCE_EXTS.map((ext) => `${name}${ext}`);
101
107
  }
@@ -2975,11 +2981,13 @@ var CF_COMPAT_SPECIFIER = "@adep/cf-compat";
2975
2981
  var STATE_OBJECTS_SPECIFIER = "@adep/runtime/state-objects";
2976
2982
  var CRYPTO_SPECIFIER = "@adep/crypto";
2977
2983
  var ROUTE_SPECIFIER = "@adep/runtime/route";
2984
+ var RESPONSE_SPECIFIER = "@adep/runtime/response";
2978
2985
  var BUILTIN_DEPS = [
2979
2986
  CF_COMPAT_SPECIFIER,
2980
2987
  STATE_OBJECTS_SPECIFIER,
2981
2988
  CRYPTO_SPECIFIER,
2982
2989
  ROUTE_SPECIFIER,
2990
+ RESPONSE_SPECIFIER,
2983
2991
  "axios",
2984
2992
  "lodash",
2985
2993
  "dayjs",
@@ -2992,7 +3000,8 @@ var LOCAL_BUILTIN_DEPS = [
2992
3000
  CF_COMPAT_SPECIFIER,
2993
3001
  STATE_OBJECTS_SPECIFIER,
2994
3002
  CRYPTO_SPECIFIER,
2995
- ROUTE_SPECIFIER
3003
+ ROUTE_SPECIFIER,
3004
+ RESPONSE_SPECIFIER
2996
3005
  ];
2997
3006
 
2998
3007
  // packages/cli/src/sim/boundary.ts
@@ -3095,6 +3104,57 @@ function asCfBindingsConfig(value, errors) {
3095
3104
  if (r2 !== null && r2 !== void 0) bindings.r2 = r2;
3096
3105
  return bindings;
3097
3106
  }
3107
+ function asTriggersConfig(value, errors) {
3108
+ if (!Array.isArray(value)) {
3109
+ errors.push(`triggers \u5FC5\u987B\u662F\u6570\u7EC4\uFF0C\u6536\u5230 ${typeNameOf(value)}`);
3110
+ return null;
3111
+ }
3112
+ const out = [];
3113
+ value.forEach((item, i) => {
3114
+ const idx = `triggers[${i}]`;
3115
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
3116
+ errors.push(`${idx} \u5FC5\u987B\u662F\u5BF9\u8C61\uFF0C\u6536\u5230 ${typeNameOf(item)}`);
3117
+ return;
3118
+ }
3119
+ const rec = item;
3120
+ if (typeof rec.function !== "string" || rec.function.length === 0) {
3121
+ errors.push(`${idx}.function \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF0C\u6536\u5230 ${typeNameOf(rec.function)}`);
3122
+ return;
3123
+ }
3124
+ if (rec.type !== "cron" && rec.type !== "storage.upload") {
3125
+ errors.push(`${idx}.type \u5FC5\u987B\u662F 'cron' \u6216 'storage.upload'\uFF0C\u6536\u5230 ${typeNameOf(rec.type)}`);
3126
+ return;
3127
+ }
3128
+ const entry = { function: rec.function, type: rec.type };
3129
+ if (rec.cron !== void 0) {
3130
+ if (typeof rec.cron !== "string") {
3131
+ errors.push(`${idx}.cron \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF0C\u6536\u5230 ${typeNameOf(rec.cron)}`);
3132
+ } else if (rec.type === "cron" && rec.cron.trim().length === 0) {
3133
+ errors.push(`${idx}.cron \u4E0D\u80FD\u4E3A\u7A7A\uFF08type='cron' \u5FC5\u987B\u7ED9\u51FA 5 \u5B57\u6BB5 cron \u8868\u8FBE\u5F0F\uFF09`);
3134
+ } else {
3135
+ entry.cron = rec.cron;
3136
+ }
3137
+ } else if (rec.type === "cron") {
3138
+ errors.push(`${idx}.cron \u7F3A\u5931\uFF1Atype='cron' \u5FC5\u987B\u7ED9\u51FA 5 \u5B57\u6BB5 cron \u8868\u8FBE\u5F0F`);
3139
+ }
3140
+ if (rec.prefix !== void 0) {
3141
+ if (typeof rec.prefix !== "string") {
3142
+ errors.push(`${idx}.prefix \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF0C\u6536\u5230 ${typeNameOf(rec.prefix)}`);
3143
+ } else {
3144
+ entry.prefix = rec.prefix;
3145
+ }
3146
+ }
3147
+ if (rec.enabled !== void 0) {
3148
+ if (typeof rec.enabled !== "boolean") {
3149
+ errors.push(`${idx}.enabled \u5FC5\u987B\u662F\u5E03\u5C14\u503C\uFF0C\u6536\u5230 ${typeNameOf(rec.enabled)}`);
3150
+ } else {
3151
+ entry.enabled = rec.enabled;
3152
+ }
3153
+ }
3154
+ out.push(entry);
3155
+ });
3156
+ return out;
3157
+ }
3098
3158
  function parseAdepConfig(raw) {
3099
3159
  if (raw === null || raw === void 0) {
3100
3160
  return { ok: true, data: { ...DEFAULT_ADEP_CONFIG } };
@@ -3159,6 +3219,10 @@ function parseAdepConfig(raw) {
3159
3219
  const bindings = asCfBindingsConfig(input.cfBindings, errors);
3160
3220
  if (bindings !== null) data.cfBindings = bindings;
3161
3221
  }
3222
+ if (input.triggers !== void 0) {
3223
+ const triggers = asTriggersConfig(input.triggers, errors);
3224
+ if (triggers !== null) data.triggers = triggers;
3225
+ }
3162
3226
  if (errors.length > 0) return { ok: false, errors };
3163
3227
  return { ok: true, data };
3164
3228
  }
@@ -4240,24 +4304,19 @@ import { readdir as readdir2, readFile as readFile6 } from "node:fs/promises";
4240
4304
  import { join as join9 } from "node:path";
4241
4305
 
4242
4306
  // packages/runtime/src/shared/function-source.ts
4243
- var SHARED_SOURCE_ROOT = "functions/_shared/";
4244
- var FUNCTION_SHARED_DIR = "_shared/";
4307
+ var SHARED_SOURCE_ROOT2 = SHARED_SOURCE_ROOT;
4308
+ var FUNCTION_SHARED_DIR3 = `${FUNCTION_SHARED_DIR}/`;
4245
4309
  function mergeSharedSource(files, shared) {
4246
4310
  const merged = { ...files };
4247
4311
  for (const file of shared) {
4248
- if (!file.path.startsWith(SHARED_SOURCE_ROOT)) continue;
4249
- const rel = file.path.slice(SHARED_SOURCE_ROOT.length);
4312
+ if (!file.path.startsWith(SHARED_SOURCE_ROOT2)) continue;
4313
+ const rel = file.path.slice(SHARED_SOURCE_ROOT2.length);
4250
4314
  if (rel === "") continue;
4251
- merged[`${FUNCTION_SHARED_DIR}${rel}`] = file.content;
4315
+ merged[`${FUNCTION_SHARED_DIR3}${rel}`] = file.content;
4252
4316
  }
4253
4317
  return merged;
4254
4318
  }
4255
4319
 
4256
- // shared/shared-source.ts
4257
- var FUNCTION_SHARED_DIR2 = "_shared";
4258
- var SHARED_SOURCE_ROOT2 = `functions/${FUNCTION_SHARED_DIR2}/`;
4259
- var FUNCTION_SHARED_MAX_TOTAL_BYTES = 256 * 1024;
4260
-
4261
4320
  // packages/cli/src/dev-shared.ts
4262
4321
  var BEARER_TOKEN_RE = /^Bearer\s+(.+)$/i;
4263
4322
  function bearerUserResolverFromEnv() {
@@ -4314,9 +4373,9 @@ async function collectFunctions(dir) {
4314
4373
  function mergeSharedIntoFunctions(files) {
4315
4374
  const shared = [];
4316
4375
  for (const [path, content] of Object.entries(files)) {
4317
- if (!path.startsWith(`${FUNCTION_SHARED_DIR2}/`)) continue;
4376
+ if (!path.startsWith(`${FUNCTION_SHARED_DIR}/`)) continue;
4318
4377
  shared.push({
4319
- path: `${SHARED_SOURCE_ROOT2}${path.slice(FUNCTION_SHARED_DIR2.length + 1)}`,
4378
+ path: `${SHARED_SOURCE_ROOT}${path.slice(FUNCTION_SHARED_DIR.length + 1)}`,
4320
4379
  content
4321
4380
  });
4322
4381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adep/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "`adep` 命令行工具包位:登录 / 初始化 / 本地调试 / 部署 / 数据库维护 / 云存储 / 静态托管 / 微前端组件(widget),实现见任务单 CLI-001 ~ CLI-003 与 FE-002;子路径 `@adep/cli/vite` 提供云函数 vite 插件(CLI-014)",
6
6
  "bin": {
@@ -22,10 +22,10 @@
22
22
  "commander": "14.0.2",
23
23
  "esbuild": "0.25.12",
24
24
  "zod": "^4.4.3",
25
- "@adep/runtime": "0.3.0",
26
- "@adep/vite-plugin": "0.1.8",
25
+ "@adep/cf-compat": "0.1.1",
26
+ "@adep/runtime": "0.3.2",
27
27
  "@adep/crypto": "0.1.0",
28
- "@adep/cf-compat": "0.1.1"
28
+ "@adep/vite-plugin": "0.1.8"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@adep/types": "0.3.0"