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
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:
651
Cargo.lock
generated
651
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
298
admin-v2/pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
151
crates/zclaw-growth/benches/retrieval_bench.rs
Normal file
151
crates/zclaw-growth/benches/retrieval_bench.rs
Normal 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);
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
107
crates/zclaw-saas/src/billing/invoice_pdf.rs
Normal file
107
crates/zclaw-saas/src/billing/invoice_pdf.rs
Normal 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)
|
||||
}
|
||||
@@ -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 — 支付宝/微信服务器回调)
|
||||
|
||||
Reference in New Issue
Block a user