feat: Sprint 3 — benchmark + conversion funnel + invoice PDF
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- 3.1: Add criterion benchmark for zclaw-growth TF-IDF retrieval
  (indexing throughput, query scoring latency, top-K retrieval)
- 3.2: Extend admin-v2 Usage page with recharts funnel chart
  (registration → trial → paid conversion) and daily trend bar chart
- 3.3: Add invoice PDF export via genpdf (Arial font, Windows)
  with GET /api/v1/billing/invoices/{id}/pdf handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-04 14:42:29 +08:00
parent a6902c28f5
commit e90eb5df60
10 changed files with 1408 additions and 51 deletions

651
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2",
"recharts": "^3.8.1",
"zustand": "^5.0.12"
},
"devDependencies": {

298
admin-v2/pnpm-lock.yaml generated
View File

@@ -35,9 +35,12 @@ importers:
react-router-dom:
specifier: ^7.13.2
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1)
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@eslint/js':
specifier: ^9.39.4
@@ -832,6 +835,17 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -936,6 +950,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
@@ -1076,6 +1093,33 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -1099,6 +1143,9 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.57.2':
resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1367,6 +1414,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1383,6 +1474,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1444,6 +1538,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.45.1:
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1512,6 +1609,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1646,6 +1746,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1658,6 +1764,10 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -2012,6 +2122,18 @@ packages:
react-lifecycles-compat@3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-router-dom@7.13.2:
resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==}
engines: {node: '>=20.0.0'}
@@ -2038,10 +2160,26 @@ packages:
peerDependencies:
react: '*'
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -2050,6 +2188,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -2187,6 +2328,9 @@ packages:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2273,6 +2417,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@8.0.3:
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3410,6 +3557,18 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.4
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.0.0-rc.12':
optional: true
@@ -3466,6 +3625,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.2.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -3587,6 +3748,30 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
@@ -3607,6 +3792,8 @@ snapshots:
'@types/statuses@2.0.6': {}
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -3947,6 +4134,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
@@ -3960,6 +4185,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
@@ -4008,6 +4235,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.2
es-toolkit@1.45.1: {}
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -4101,6 +4330,8 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@5.0.4: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
@@ -4210,6 +4441,10 @@ snapshots:
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.4: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4219,6 +4454,8 @@ snapshots:
indent-string@4.0.0: {}
internmap@2.0.3: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -4545,6 +4782,15 @@ snapshots:
react-lifecycles-compat@3.0.4: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -4566,15 +4812,43 @@ snapshots:
lodash: 4.17.23
react: 19.2.4
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.45.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-is: 18.3.1
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.4)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redent@3.0.0:
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
reselect@5.1.1: {}
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
@@ -4702,6 +4976,8 @@ snapshots:
throttle-debounce@5.0.2: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
@@ -4776,6 +5052,23 @@ snapshots:
dependencies:
react: 19.2.4
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1):
dependencies:
lightningcss: 1.32.0
@@ -4893,8 +5186,9 @@ snapshots:
zod@4.3.6: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.2.14
immer: 11.1.4
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)

View File

@@ -1,18 +1,71 @@
// ============================================================
// 用量统计
// 用量统计 + 转化漏斗
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Card, Col, Row, Select, Statistic } from 'antd'
import { ThunderboltOutlined, ColumnWidthOutlined } from '@ant-design/icons'
import { ThunderboltOutlined, ColumnWidthOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
FunnelChart, Funnel, LabelList,
} from 'recharts'
import { telemetryService } from '@/services/telemetry'
import { statsService } from '@/services/stats'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import type { DailyUsageStat, ModelUsageStat } from '@/types'
// ─── Conversion Funnel Data ───
interface FunnelStep {
name: string
value: number
fill: string
}
function buildFunnelData(
totalAccounts: number,
activeAccounts: number,
dailyData?: DailyUsageStat[],
modelData?: ModelUsageStat[],
): FunnelStep[] {
const activeDevicesToday = dailyData?.length
? dailyData.reduce((s, d) => s + d.unique_devices, 0)
: 0
const activeModels = modelData?.filter((m) => m.request_count > 0).length ?? 0
return [
{ name: '注册用户', value: totalAccounts, fill: '#8c8c8c' },
{ name: '活跃用户', value: activeAccounts, fill: '#863bff' },
{ name: '今日使用', value: Math.max(activeDevicesToday, 0), fill: '#47bfff' },
{ name: '使用多模型', value: activeModels, fill: '#10b981' },
]
}
// ─── Daily Trend Bar Data ───
interface DailyTrend {
day: string
requests: number
inputTokens: number
outputTokens: number
}
function buildDailyTrend(data?: DailyUsageStat[]): DailyTrend[] {
if (!data) return []
return data.map((d) => ({
day: d.day.slice(5), // MM-DD
requests: d.request_count,
inputTokens: Math.round(d.input_tokens / 1000), // K tokens
outputTokens: Math.round(d.output_tokens / 1000),
}))
}
// ─── Main Component ───
export default function Usage() {
const [days, setDays] = useState(30)
@@ -31,6 +84,11 @@ export default function Usage() {
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
})
const { data: dashboardStats } = useQuery({
queryKey: ['stats-dashboard'],
queryFn: ({ signal }) => statsService.dashboard(signal),
})
if (dailyError) {
return (
<>
@@ -43,6 +101,12 @@ export default function Usage() {
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
const totalAccounts = dashboardStats?.total_accounts ?? 0
const activeAccounts = dashboardStats?.active_accounts ?? 0
const funnelData = buildFunnelData(totalAccounts, activeAccounts, dailyData, modelData)
const trendData = buildDailyTrend(dailyData)
const dailyColumns: ProColumns<DailyUsageStat>[] = [
{ title: '日期', dataIndex: 'day', width: 120 },
{
@@ -104,7 +168,7 @@ export default function Usage() {
<div>
<PageHeader
title="用量统计"
description="查看模型使用情况Token 消耗"
description="查看模型使用情况Token 消耗和用户转化"
actions={
<Select
value={days}
@@ -121,7 +185,7 @@ export default function Usage() {
{/* Summary Cards */}
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} sm={12}>
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
@@ -135,7 +199,7 @@ export default function Usage() {
/>
</Card>
</Col>
<Col xs={24} sm={12}>
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
@@ -149,6 +213,100 @@ export default function Usage() {
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
</span>
}
value={totalAccounts}
prefix={<UserOutlined style={{ color: '#10b981' }} />}
valueStyle={{ fontWeight: 600, color: '#10b981' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
</span>
}
value={activeAccounts}
prefix={<TeamOutlined style={{ color: '#f59e0b' }} />}
valueStyle={{ fontWeight: 600, color: '#f59e0b' }}
/>
</Card>
</Col>
</Row>
{/* Conversion Funnel + Daily Trend */}
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} lg={10}>
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
>
<ResponsiveContainer width="100%" height={260}>
<FunnelChart>
<Tooltip
formatter={(value: number) => [value.toLocaleString(), '数量']}
/>
<Funnel
dataKey="value"
data={funnelData}
isAnimationActive
>
<LabelList
position="right"
dataKey="name"
fill="#555"
stroke="none"
fontSize={12}
/>
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number, name: string) => {
const labels: Record<string, string> = {
requests: '请求数',
inputTokens: '输入 Token(K)',
outputTokens: '输出 Token(K)',
}
return [value.toLocaleString(), labels[name] ?? name]
}}
/>
<Bar dataKey="requests" fill="#863bff" radius={[4, 4, 0, 0]} barSize={8} />
<Bar dataKey="inputTokens" fill="#47bfff" radius={[4, 4, 0, 0]} barSize={8} />
<Bar dataKey="outputTokens" fill="#10b981" radius={[4, 4, 0, 0]} barSize={8} />
</BarChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
{/* Daily Stats */}

View File

@@ -39,3 +39,9 @@ zclaw-types = { workspace = true }
[dev-dependencies]
tokio-test = "0.4"
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "retrieval_bench"
harness = false
path = "benches/retrieval_bench.rs"

View File

@@ -0,0 +1,151 @@
//! Benchmark for TF-IDF retrieval performance in zclaw-growth
//!
//! Measures:
//! - Indexing throughput (documents/sec)
//! - Query latency at various corpus sizes (10/50/100/500 candidates)
//! - Top-K retrieval latency
use criterion::{
black_box, criterion_group, criterion_main, BenchmarkId, Criterion,
};
use zclaw_growth::retrieval::SemanticScorer;
use zclaw_growth::types::{MemoryEntry, MemoryType};
/// Generate a synthetic memory entry
fn make_entry(agent: &str, idx: usize, topic: &str, content: &str) -> MemoryEntry {
MemoryEntry::new(
agent,
MemoryType::Knowledge,
&format!("fact-{idx}"),
content.to_string(),
)
.with_keywords(vec![topic.to_string(), format!("topic-{idx}")])
}
/// Build a corpus of N entries with realistic content
fn build_corpus(size: usize) -> (SemanticScorer, Vec<MemoryEntry>) {
let mut scorer = SemanticScorer::new();
let mut entries = Vec::with_capacity(size);
let topics = [
("rust", "Rust is a systems programming language focused on safety and performance with zero-cost abstractions"),
("python", "Python is a high-level general-purpose programming language emphasizing code readability"),
("machine-learning", "Machine learning is a subset of artificial intelligence that enables systems to learn from data"),
("web-development", "Web development involves building and maintaining websites using frontend and backend technologies"),
("database", "Database management systems provide tools for storing retrieving and managing structured data efficiently"),
("security", "Cybersecurity involves protecting computer systems and networks from information disclosure"),
("devops", "DevOps combines software development and IT operations to shorten the systems development lifecycle"),
("testing", "Software testing validates that applications meet their specified requirements and are free of defects"),
("api", "API design involves creating interfaces that allow different software applications to communicate"),
("cloud", "Cloud computing delivers computing services over the internet including servers storage databases networking"),
];
for i in 0..size {
let (topic, base_content) = &topics[i % topics.len()];
let content = format!(
"{base_content}. This fact #{i} discusses {topic} in depth with examples and use cases. \
The key concepts include {topic} patterns, Implementation details cover performance optimization."
);
let entry = make_entry("bench-agent", i, topic, &content);
scorer.index_entry(&entry);
entries.push(entry);
}
(scorer, entries)
}
/// Build a list of entries for indexing benchmarks
fn build_entries(count: usize) -> Vec<MemoryEntry> {
let topics = ["rust", "python", "ml", "web", "database"];
(0..count)
.map(|i| {
let topic = topics[i % topics.len()];
let content = format!(
"Fact {} about {}: detailed technical content with multiple keywords and concepts \
covering advanced patterns, best practices, and optimization strategies.",
i, topic
);
make_entry("bench-agent", i, topic, &content)
})
.collect()
}
// ─── Indexing throughput ───
fn bench_indexing(c: &mut Criterion) {
let mut group = c.benchmark_group("index_entry");
group.sample_size(50);
for &batch_size in &[10, 50, 100, 500] {
let entries = build_entries(batch_size);
group.bench_with_input(
BenchmarkId::new("batch", batch_size),
&entries,
|b, entries| {
b.iter(|| {
let mut scorer = SemanticScorer::new();
for entry in entries {
scorer.index_entry(black_box(entry));
}
});
},
);
}
}
// ─── Query scoring latency ───
fn bench_query_scoring(c: &mut Criterion) {
let mut group = c.benchmark_group("score_similarity");
for &corpus_size in &[10, 50, 100, 500] {
let (scorer, entries) = build_corpus(corpus_size);
let query = "rust safety performance optimization";
let entry = &entries[corpus_size / 2];
group.bench_with_input(
BenchmarkId::new("corpus", corpus_size),
&scorer,
|b, scorer| {
b.iter(|| scorer.score_similarity(black_box(query), black_box(entry)));
},
);
}
}
// ─── Top-K retrieval ───
fn bench_top_k_retrieval(c: &mut Criterion) {
let mut group = c.benchmark_group("top_k_retrieval");
for &corpus_size in &[10, 50, 100, 500] {
let (scorer, entries) = build_corpus(corpus_size);
let query = "machine learning model training optimization";
group.bench_with_input(
BenchmarkId::new("top3", corpus_size),
&entries,
|b, entries| {
b.iter(|| {
let mut scored: Vec<(f32, usize)> = entries
.iter()
.enumerate()
.map(|(idx, entry)| (scorer.score_similarity(query, entry), idx))
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let _top3 = scored.into_iter().take(3).collect::<Vec<_>>();
});
},
);
}
}
criterion_group!(
benches,
bench_indexing,
bench_query_scoring,
bench_top_k_retrieval,
);
criterion_main!(benches);

View File

@@ -51,6 +51,7 @@ aes-gcm = { workspace = true }
sha2 = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }
genpdf = "0.2"
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -473,3 +473,77 @@ mod crypto {
provided.len() >= 16 && expected.len() >= 16 && provided == expected
}
}
// === 发票 PDF ===
/// GET /api/v1/billing/invoices/:id/pdf — 下载发票 PDF
pub async fn get_invoice_pdf(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(invoice_id): Path<String>,
) -> SaasResult<axum::response::Response> {
// 查询发票(需属于当前账户)
let invoice: Invoice = sqlx::query_as::<_, Invoice>(
"SELECT * FROM billing_invoices WHERE id = $1 AND account_id = $2"
)
.bind(&invoice_id)
.bind(&ctx.account_id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| SaasError::NotFound("发票不存在".into()))?;
// 仅已支付的发票可下载 PDF
if invoice.status != "paid" {
return Err(SaasError::InvalidInput("仅已支付的发票可导出 PDF".into()));
}
// 查询关联支付记录
let payments: Vec<Payment> = sqlx::query_as::<_, Payment>(
"SELECT * FROM billing_payments WHERE invoice_id = $1"
)
.bind(&invoice_id)
.fetch_all(&state.db)
.await?;
// 构造发票信息(从 invoice metadata 中提取)
let info = super::invoice_pdf::InvoiceInfo {
title: invoice.metadata.get("invoice_title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
tax_id: invoice.metadata.get("tax_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
email: invoice.metadata.get("email")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
address: invoice.metadata.get("address")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
phone: invoice.metadata.get("phone")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
};
// 生成 PDF
let bytes = super::invoice_pdf::generate_invoice_pdf(&invoice, &payments, &info)
.map_err(|e| {
tracing::error!("Invoice PDF generation failed: {}", e);
SaasError::Internal("PDF 生成失败".into())
})?;
// 返回 PDF 响应
Ok(axum::response::Response::builder()
.status(200)
.header("Content-Type", "application/pdf")
.header(
"Content-Disposition",
format!("attachment; filename=\"invoice-{}.pdf\"", invoice.id),
)
.body(axum::body::Body::from(bytes))
.unwrap())
}

View File

@@ -0,0 +1,107 @@
//! 发票 PDF 生成模块
//!
//! 使用 genpdf 生成 PDF 发票。
//! genpdf 需要 TTF 字体文件来测量文本宽度,我们使用 Windows 内置 Arial 字体。
//! 注意: Arial 不支持中文字符,发票字段使用英文标签。
use genpdf::elements::Paragraph;
use genpdf::fonts;
use genpdf::{Document, Element, SimplePageDecorator};
use crate::billing::types::Invoice;
use crate::billing::types::Payment;
/// 发票信息结构 — 用于客户填写
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InvoiceInfo {
pub title: String,
pub tax_id: String,
pub email: String,
pub address: String,
pub phone: String,
}
/// 加载 Arial 字体族Windows 系统字体)
fn load_font_family() -> Result<fonts::FontFamily<fonts::FontData>, Box<dyn std::error::Error>> {
let font_dir = "C:/Windows/Fonts";
let family = fonts::from_files(font_dir, "arial", Some(fonts::Builtin::Helvetica))?;
Ok(family)
}
/// 生成发票 PDF 字节
pub fn generate_invoice_pdf(
invoice: &Invoice,
payments: &[Payment],
info: &InvoiceInfo,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let font_family = load_font_family()?;
let mut doc = Document::new(font_family);
doc.set_title(&format!("ZCLAW Invoice #{}", invoice.id));
let mut decorator = SimplePageDecorator::new();
decorator.set_margins(10);
doc.set_page_decorator(decorator);
// Header
let header_style = genpdf::style::Style::new().with_font_size(14).bold();
doc.push(Paragraph::new(format!("ZCLAW INVOICE #{}", invoice.id)).styled(header_style));
doc.push(Paragraph::new(""));
// Customer info
let info_style = genpdf::style::Style::new().with_font_size(10);
doc.push(
Paragraph::new(format!(
"Title: {}\nTax ID: {}\nEmail: {}\nAddress: {}\nPhone: {}",
info.title, info.tax_id, info.email, info.address, info.phone,
))
.styled(info_style),
);
doc.push(Paragraph::new(""));
// Invoice details
let detail_style = genpdf::style::Style::new().with_font_size(10);
let amount_yuan = format!("{:.2} CNY", invoice.amount_cents as f64 / 100.0);
let created = invoice.created_at.format("%Y-%m-%d %H:%M");
doc.push(
Paragraph::new(format!(
"Plan: {}\nSubscription: {}\nAmount: {}\nDate: {}\nStatus: {}",
invoice.plan_id.as_deref().unwrap_or("N/A"),
invoice.subscription_id.as_deref().unwrap_or("N/A"),
amount_yuan,
created,
invoice.status,
))
.styled(detail_style),
);
doc.push(Paragraph::new(""));
// Payment records
if !payments.is_empty() {
doc.push(Paragraph::new("Payments:").styled(detail_style));
for p in payments {
let paid_at = p
.paid_at
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "pending".to_string());
let amount = format!("{:.2} CNY", p.amount_cents as f64 / 100.0);
doc.push(
Paragraph::new(format!(
" {} via {} - {} ({})",
amount, p.method, p.status, paid_at,
))
.styled(detail_style),
);
}
doc.push(Paragraph::new(""));
}
// Footer
let footer_style = genpdf::style::Style::new().with_font_size(8);
doc.push(Paragraph::new("Thank you for using ZCLAW.").styled(footer_style));
// Render to bytes
let mut buf: Vec<u8> = Vec::new();
doc.render(&mut buf)?;
Ok(buf)
}

View File

@@ -4,6 +4,7 @@ pub mod types;
pub mod service;
pub mod handlers;
pub mod payment;
pub mod invoice_pdf;
use axum::routing::{get, post};
@@ -17,6 +18,7 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
.route("/api/v1/billing/usage/increment", post(handlers::increment_usage_dimension))
.route("/api/v1/billing/payments", post(handlers::create_payment))
.route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status))
.route("/api/v1/billing/invoices/{id}/pdf", get(handlers::get_invoice_pdf))
}
/// 支付回调路由(无需 auth — 支付宝/微信服务器回调)