diff --git a/banban-mini/config/dev.js b/banban-mini/config/dev.js index 77f8f0d..7dcc8a3 100644 --- a/banban-mini/config/dev.js +++ b/banban-mini/config/dev.js @@ -3,7 +3,10 @@ module.exports = { NODE_ENV: '"development"' }, defineConstants: { + __APP_ENV__: '"development"', + // Update this to your LAN backend when testing on a real device. + __API_BASE_URL__: '"http://192.168.101.86:8001"', }, mini: {}, h5: {} -} \ No newline at end of file +} diff --git a/banban-mini/config/index.js b/banban-mini/config/index.js index ee013d5..8df66fb 100644 --- a/banban-mini/config/index.js +++ b/banban-mini/config/index.js @@ -1,6 +1,6 @@ const path = require('path') -module.exports = { +const config = { projectName: 'banban-mini', date: '2025-3-3', designWidth: 750, @@ -12,31 +12,38 @@ module.exports = { sourceRoot: 'src', outputRoot: 'dist', plugins: [], - defineConstants: {}, + alias: { + '@': path.resolve(__dirname, '..', 'src'), + react: path.resolve(__dirname, '..', 'node_modules/react') + }, + defineConstants: { + }, copy: { patterns: [ { from: 'src/assets/tab-icons', to: 'dist/assets/tab-icons' } ], - options: {} + options: { + } }, framework: 'react', mini: { - webpackChain(chain) { - chain.resolve.alias.set('@', path.resolve(__dirname, '../src')) - }, postcss: { pxtransform: { enable: true, - config: {} + config: { + + } }, url: { enable: true, - config: { limit: 1024 } + config: { + limit: 1024 // 设定转换尺寸上限 + } }, cssModules: { - enable: false, + enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true config: { - namingPattern: 'module', + namingPattern: 'module', // 转换模式,取值为 global/module generateScopedName: '[name]__[local]___[hash:base64:5]' } } @@ -45,18 +52,26 @@ module.exports = { h5: { publicPath: '/', staticDirectory: 'static', - webpackChain(chain) { - chain.resolve.alias.set('@', path.resolve(__dirname, '../src')) - }, postcss: { - autoprefixer: { enable: true, config: {} }, - cssModules: { - enable: false, + autoprefixer: { + enable: true, config: { - namingPattern: 'module', + } + }, + cssModules: { + enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true + config: { + namingPattern: 'module', // 转换模式,取值为 global/module generateScopedName: '[name]__[local]___[hash:base64:5]' } } } } -} \ No newline at end of file +} + +module.exports = function (merge) { + if (process.env.NODE_ENV === 'development') { + return merge({}, config, require('./dev')) + } + return merge({}, config, require('./prod')) +} diff --git a/banban-mini/config/prod.js b/banban-mini/config/prod.js index 347b6b5..a0664a7 100644 --- a/banban-mini/config/prod.js +++ b/banban-mini/config/prod.js @@ -3,7 +3,10 @@ module.exports = { NODE_ENV: '"production"' }, defineConstants: { + __APP_ENV__: '"production"', + // Replace with the production backend before release builds. + __API_BASE_URL__: '"http://192.168.101.86:8001"', }, mini: {}, h5: {} -} \ No newline at end of file +} diff --git a/banban-mini/package-lock.json b/banban-mini/package-lock.json index e1e4fef..5ecff58 100644 --- a/banban-mini/package-lock.json +++ b/banban-mini/package-lock.json @@ -13,8 +13,8 @@ "@tarojs/react": "^3.6.38", "@tarojs/runtime": "^3.6.38", "@tarojs/taro": "^3.6.38", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "18.2.0", + "react-dom": "18.2.0", "webpack": "^4.47.0" }, "devDependencies": { @@ -25,7 +25,6 @@ "@types/react": "^18.0.0", "@types/webpack-env": "^1.13.9", "babel-preset-taro": "^3.6.38", - "cross-env": "^10.1.0", "typescript": "^4.1.0" } }, @@ -165,10 +164,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", - "license": "MIT", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", @@ -1866,12 +1864,11 @@ } }, "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.0", - "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", - "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", - "license": "MIT", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { @@ -2028,13 +2025,6 @@ "stackframe": "^1.1.1" } }, - "node_modules/@epic-web/invariant": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "dev": true, - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.12", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", @@ -2457,11 +2447,10 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", + "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", "dev": true, - "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -2489,14 +2478,13 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", + "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -2520,11 +2508,10 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" + "dev": true }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", @@ -3979,117 +3966,15 @@ "node": ">= 10" } }, - "node_modules/@tarojs/plugin-doctor/node_modules/@eslint/js": { - "version": "8.41.0", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.41.0.tgz", - "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@tarojs/plugin-doctor/node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, "node_modules/@tarojs/plugin-doctor/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/@tarojs/plugin-doctor/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@tarojs/plugin-doctor/node_modules/eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.41.0.tgz", - "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.41.0", - "@humanwhocodes/config-array": "^0.11.8", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.5.2", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@tarojs/plugin-doctor/node_modules/glob": { "version": "10.2.6", "resolved": "https://registry.npmmirror.com/glob/-/glob-10.2.6.tgz", @@ -4113,25 +3998,11 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@tarojs/plugin-doctor/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@tarojs/plugin-doctor/node_modules/glob/node_modules/minimatch": { + "node_modules/@tarojs/plugin-doctor/node_modules/minimatch": { "version": "9.0.9", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" }, @@ -5066,13 +4937,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, "node_modules/@vue/compiler-core": { "version": "3.5.29", "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz", @@ -6488,10 +6352,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7817,10 +7680,9 @@ } }, "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, "node_modules/cosmiconfig": { "version": "7.1.0", @@ -7892,24 +7754,6 @@ "sha.js": "^2.4.8" } }, - "node_modules/cross-env": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", - "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@epic-web/invariant": "^1.0.0", - "cross-spawn": "^7.0.6" - }, - "bin": { - "cross-env": "dist/bin/cross-env.js", - "cross-env-shell": "dist/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8904,11 +8748,10 @@ } }, "node_modules/depcheck/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -9837,30 +9680,28 @@ } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", + "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.41.0", + "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", + "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -9870,6 +9711,7 @@ "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", + "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", @@ -9879,8 +9721,9 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", + "optionator": "^0.9.1", "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -13885,22 +13728,19 @@ } }, "node_modules/less": { - "version": "4.5.1", - "resolved": "https://registry.npmmirror.com/less/-/less-4.5.1.tgz", - "integrity": "sha512-UKgI3/KON4u6ngSsnDADsUERqhZknsVZbnuzlRZXLQCmfC/MDld42fTydUE9B+Mla1AL6SJ/Pp6SlEFi/AVGfw==", + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz", + "integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", "dependencies": { - "copy-anything": "^2.0.1", - "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" + "copy-anything": "^3.0.5", + "parse-node-version": "^1.0.1" }, "bin": { "lessc": "bin/lessc" }, "engines": { - "node": ">=14" + "node": ">=18" }, "optionalDependencies": { "errno": "^0.1.1", @@ -13969,6 +13809,33 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/less/node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/less/node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "dev": true, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/less/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", @@ -14530,11 +14397,10 @@ } }, "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -14552,16 +14418,6 @@ "node": ">= 0.6" } }, - "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -14727,13 +14583,12 @@ } }, "node_modules/minipass": { - "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/minipass/-/minipass-6.0.2.tgz", - "integrity": "sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "dev": true, - "license": "ISC", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=8" } }, "node_modules/minipass-collect": { @@ -16835,9 +16690,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "funding": [ { "type": "opencollective", @@ -16852,7 +16707,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17516,7 +17370,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" }, @@ -17528,7 +17381,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -18728,7 +18580,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" } @@ -20268,16 +20119,6 @@ "node": ">=10" } }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", @@ -21498,10 +21339,9 @@ "license": "MIT" }, "node_modules/url/node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dependencies": { "side-channel": "^1.1.0" }, @@ -21627,13 +21467,6 @@ "extsprintf": "^1.2.0" } }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT" - }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -23324,11 +23157,10 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "dev": true, - "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/banban-mini/package.json b/banban-mini/package.json index 2d41c9b..8d57201 100644 --- a/banban-mini/package.json +++ b/banban-mini/package.json @@ -4,8 +4,10 @@ "private": true, "description": "伴伴微信小程序", "scripts": { - "build:weapp": "cross-env NODE_OPTIONS=--openssl-legacy-provider taro build --type weapp", - "dev:weapp": "cross-env NODE_OPTIONS=--openssl-legacy-provider taro build --type weapp --watch" + "dev": "npm run dev:weapp", + "build": "npm run build:weapp", + "build:weapp": "NODE_OPTIONS=--openssl-legacy-provider taro build --type weapp", + "dev:weapp": "NODE_OPTIONS=--openssl-legacy-provider taro build --type weapp --watch" }, "dependencies": { "@tarojs/components": "^3.6.38", @@ -13,8 +15,8 @@ "@tarojs/react": "^3.6.38", "@tarojs/runtime": "^3.6.38", "@tarojs/taro": "^3.6.38", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "18.2.0", + "react-dom": "18.2.0", "webpack": "^4.47.0" }, "devDependencies": { @@ -25,7 +27,6 @@ "@types/react": "^18.0.0", "@types/webpack-env": "^1.13.9", "babel-preset-taro": "^3.6.38", - "cross-env": "^10.1.0", "typescript": "^4.1.0" } } diff --git a/banban-mini/project.config.json b/banban-mini/project.config.json index 9f3a3c5..18a8e44 100644 --- a/banban-mini/project.config.json +++ b/banban-mini/project.config.json @@ -22,7 +22,7 @@ "uploadWithSourceMap": true, "compileHotReLoad": false, "lazyloadPlaceholderEnable": false, - "useMultiFrameRuntime": true, + "useMultiFrameRuntime": false, "useApiHook": true, "useApiHostProcess": true, "babelSetting": { @@ -30,7 +30,7 @@ "disablePlugins": [], "outputPath": "" }, - "useIsolateContext": true, + "useIsolateContext": false, "userConfirmedBundleSwitch": false, "packNpmManually": false, "packNpmRelationList": [], diff --git a/banban-mini/src/app.tsx b/banban-mini/src/app.tsx index b5e0118..143d6b2 100644 --- a/banban-mini/src/app.tsx +++ b/banban-mini/src/app.tsx @@ -1,12 +1,8 @@ -import { useLaunch } from '@tarojs/taro' import './app.scss' +import { PropsWithChildren } from 'react' -function App(props) { - useLaunch(() => { - console.log('App launched.') - }) - +function App(props: PropsWithChildren) { return props.children } -export default App \ No newline at end of file +export default App diff --git a/banban-mini/src/config/env.ts b/banban-mini/src/config/env.ts new file mode 100644 index 0000000..514bce5 --- /dev/null +++ b/banban-mini/src/config/env.ts @@ -0,0 +1,14 @@ +declare const __APP_ENV__: 'development' | 'production' +declare const __API_BASE_URL__: string + +export interface AppConfig { + appEnv: 'development' | 'production' + apiBaseUrl: string +} + +export const APP_CONFIG: AppConfig = { + appEnv: typeof __APP_ENV__ === 'undefined' ? 'development' : __APP_ENV__, + apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'http://127.0.0.1:8001' : __API_BASE_URL__, +} + +export const API_BASE_URL = APP_CONFIG.apiBaseUrl diff --git a/banban-mini/src/pages/bind/index.scss b/banban-mini/src/pages/bind/index.scss index 50b3718..007b012 100644 --- a/banban-mini/src/pages/bind/index.scss +++ b/banban-mini/src/pages/bind/index.scss @@ -6,67 +6,6 @@ padding-bottom: calc(120px + env(safe-area-inset-bottom)); } -.loading { - display: flex; - align-items: center; - justify-content: center; - height: 400px; - font-size: 30px; - color: #999999; -} - -.empty-tip { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 70%; - font-size: 30px; - color: #999999; - padding: 0 48px; - - .empty-title { - font-size: 36px; - color: #1A1A1A; - font-weight: 600; - margin-bottom: 12px; - } - - .empty-desc { - font-size: 26px; - color: #999999; - margin-bottom: 48px; - } - - .empty-input-wrap { - width: 100%; - margin-bottom: 24px; - - .empty-input { - background: #F5F7FA; - border-radius: 12px; - padding: 24px; - font-size: 30px; - width: 100%; - height: 96px; - box-sizing: border-box; - text-align: center; - } - } - - .empty-btn { - background: #FF8C42; - border-radius: 12px; - padding: 24px 64px; - - .empty-btn-text { - color: #FFFFFF; - font-size: 32px; - font-weight: 500; - } - } -} - .icon-bg { width: 72px; height: 72px; @@ -214,6 +153,125 @@ } } +.bind-form { + margin: 0 48px 48px; +} + +.form-card { + background: #FFFFFF; + border-radius: 20px; + padding: 28px 32px; + margin-bottom: 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.form-title { + font-size: 30px; + font-weight: 700; + color: #1A1A1A; + display: block; + margin-bottom: 20px; +} + +.field-item { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } +} + +.field-label { + display: block; + font-size: 26px; + color: #666666; + margin-bottom: 12px; +} + +.field-input { + width: 100%; + height: 88px; + background: #F7F8FA; + border-radius: 16px; + padding: 0 24px; + font-size: 28px; + color: #1A1A1A; + box-sizing: border-box; +} + +.field-hint { + display: block; + font-size: 24px; + color: #999999; + line-height: 1.5; +} + +.pending-tip { + margin-top: 20px; + padding: 20px 24px; + background: #FFF7E8; + border-radius: 16px; +} + +.pending-tip-text { + font-size: 26px; + color: #D46B08; +} + +.child-list { + display: flex; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.child-item { + padding: 14px 24px; + border-radius: 999px; + background: #F5F7FA; + margin-right: 16px; + margin-bottom: 16px; + + &.selected { + background: #FF8C42; + + .child-item-text { + color: #FFFFFF; + } + } +} + +.child-item-text { + font-size: 26px; + color: #666666; +} + +.submit-btn { + width: 100%; + height: 96px; + background: #FF8C42; + border-radius: 16px; + color: #FFFFFF; + font-size: 32px; + font-weight: 600; + border: none; + + &::after { + display: none; + } +} + +.loading { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + + text { + font-size: 32px; + color: #999999; + } +} + .bind-tips { margin: 0 48px; diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 3720e28..aaf8d6f 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -1,201 +1,233 @@ -import { View, Text, Image, Input } from '@tarojs/components' -import { useState, useEffect } from 'react' -import Taro from '@tarojs/taro' -import { getToken } from '@/services/api' -import { getCurrentBinding, directBind, setBindingChild } from '@/services/binding' -import { getChildren, createChild } from '@/services/child' +import { View, Text, Image, Input, Button } from '@tarojs/components' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/auth' +import { directBind, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId } from '@/services/binding' +import { Child, createChild, getChildren } from '@/services/child' import './index.scss' -interface Child { - child_id: number - child_name: string +function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } { + const raw = String(rawValue || '').trim() + if (!raw) return { deviceId: '', serialNumber: '' } + + try { + const parsed = JSON.parse(raw) + return { + deviceId: String(parsed?.device_id || parsed?.deviceId || '').trim(), + serialNumber: String(parsed?.serial_number || parsed?.serialNumber || '').trim(), + } + } catch (error) { + console.log('[bind] scan payload is not json:', error) + } + + const queryPart = raw.includes('?') ? raw.slice(raw.indexOf('?') + 1) : raw + if (queryPart.includes('=')) { + const pairMap = queryPart.split('&').reduce>((result, item) => { + const [key, ...restValue] = item.split('=') + if (!key) return result + result[decodeURIComponent(key)] = decodeURIComponent(restValue.join('=') || '') + return result + }, {}) + const deviceId = pairMap.device_id || pairMap.deviceId || '' + const serialNumber = pairMap.serial_number || pairMap.serialNumber || pairMap.sn || '' + if (deviceId || serialNumber) { + return { + deviceId: deviceId.trim(), + serialNumber: serialNumber.trim(), + } + } + } + + const deviceIdMatch = raw.match(/device(?:_id|Id)?[:=]([A-Za-z0-9_-]+)/i) + const serialNumberMatch = raw.match(/serial(?:_number|Number)?[:=]([A-Za-z0-9_-]+)/i) + if (deviceIdMatch || serialNumberMatch) { + return { + deviceId: deviceIdMatch?.[1] || '', + serialNumber: serialNumberMatch?.[1] || '', + } + } + + const parts = raw.split(/[\s,;|]+/).filter(Boolean) + if (parts.length >= 2) { + return { + deviceId: parts[0], + serialNumber: parts[1], + } + } + + return { + deviceId: raw, + serialNumber: '', + } } export default function Bind() { - const [isScanning, setIsScanning] = useState(false) const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [isScanning, setIsScanning] = useState(false) + const [deviceId, setDeviceId] = useState('') + const [serialNumber, setSerialNumber] = useState('') const [children, setChildren] = useState([]) + const [selectedChildId, setSelectedChildId] = useState(null) const [newChildName, setNewChildName] = useState('') - const [saving, setSaving] = useState(false) const [pendingDeviceId, setPendingDeviceId] = useState(null) - useEffect(() => { - checkAuth() - }, []) + useDidShow(() => { + void loadPageData() + }) const goDevicePage = () => { - setTimeout(() => { - Taro.reLaunch({ url: '/pages/device/index' }) - }, 500) + Taro.reLaunch({ url: '/pages/device/index' }) } - const completeChildLink = async (deviceId: string, childId: number) => { - await setBindingChild(deviceId, { child_id: childId }) - setPendingDeviceId(null) - } - - const checkAuth = async () => { - const token = getToken() - if (!token) { - Taro.redirectTo({ url: '/pages/login/index' }) + const loadPageData = async () => { + if (!getToken()) { + Taro.reLaunch({ url: '/pages/login/index' }) return } + setLoading(true) try { - const childRes = await getChildren() - const currentChildren = childRes.items || [] + const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()]) + const currentChildren = childResponse.items || [] + setChildren(currentChildren) + setSelectedChildId((currentSelectedChildId) => currentSelectedChildId || currentChildren[0]?.child_id || null) - const binding = await getCurrentBinding() - if (binding) { - if (binding.child_id) { - Taro.showToast({ title: 'Already bound', icon: 'none' }) - goDevicePage() - return - } - - setPendingDeviceId(binding.device_id) - if (currentChildren.length > 0) { - await completeChildLink(binding.device_id, currentChildren[0].child_id) - Taro.showToast({ title: 'Binding completed', icon: 'success' }) - goDevicePage() - return - } + if (activeBinding?.device_id && !activeBinding.child_id) { + setPendingDeviceId(activeBinding.device_id) + setDeviceId(activeBinding.device_id) + } else { + setPendingDeviceId(null) + setDeviceId('') + setSerialNumber('') } - } catch (err) { - console.error('Load bind page data failed:', err) + } catch (error: any) { + console.error('[bind] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) } finally { setLoading(false) } } - const doBind = async (deviceId: string) => { - Taro.showLoading({ title: 'Binding...' }) + const ensureChildId = async (): Promise => { + if (selectedChildId) return selectedChildId - try { - await directBind({ device_id: deviceId }) + const childName = newChildName.trim() + if (!childName) return null - if (children.length > 0) { - await completeChildLink(deviceId, children[0].child_id) - Taro.hideLoading() - Taro.showToast({ - title: 'Bind success', - icon: 'success', - duration: 1200, - }) - goDevicePage() - return - } - - setPendingDeviceId(deviceId) - Taro.hideLoading() - Taro.showToast({ title: 'Device bound, add child next', icon: 'none' }) - } catch (err: any) { - Taro.hideLoading() - Taro.showToast({ title: err.message || 'Bind failed', icon: 'none' }) - } + const child = await createChild({ child_name: childName }) + setChildren((currentChildren) => [child, ...currentChildren]) + setSelectedChildId(child.child_id) + setNewChildName('') + return child.child_id } const handleScanCode = () => { - if (pendingDeviceId) { - Taro.showToast({ title: 'Complete child setup first', icon: 'none' }) - return - } - setIsScanning(true) Taro.scanCode({ onlyFromCamera: true, scanType: ['qrCode', 'barCode'], - success: async (res) => { + success: (res) => { setIsScanning(false) - const deviceId = res.result + const parsed = parseBindingPayload(res.result || '') + if (parsed.deviceId) setDeviceId(parsed.deviceId) + if (parsed.serialNumber) setSerialNumber(parsed.serialNumber) - if (!deviceId) { - Taro.showToast({ title: 'Invalid device code', icon: 'none' }) - return - } - - await doBind(deviceId) + Taro.showToast({ + title: parsed.serialNumber ? '已填入设备信息' : '已识别设备号,请补充序列号', + icon: 'none', + }) }, fail: (err) => { setIsScanning(false) + if (err.errMsg && err.errMsg.includes('cancel')) { return } - Taro.showToast({ title: 'Scan failed', icon: 'none' }) + + Taro.showToast({ + title: '扫码失败,请重试', + icon: 'none', + }) }, }) } - const handleManualInput = () => { - if (pendingDeviceId) { - Taro.showToast({ title: 'Complete child setup first', icon: 'none' }) - return - } + const handleSubmit = async () => { + if (submitting) return - Taro.showModal({ - title: 'Manual input', - editable: true, - placeholderText: 'Enter device code', - success: (res) => { - if (res.confirm && res.content) { - doBind(res.content) - } - }, - }) - } + setSubmitting(true) + Taro.showLoading({ title: pendingDeviceId ? '关联中...' : '绑定中...' }) - const handleAddChild = async () => { - if (!newChildName.trim()) { - Taro.showToast({ title: 'Please enter child name', icon: 'none' }) - return - } - - setSaving(true) try { - const child = await createChild({ child_name: newChildName.trim() }) - setNewChildName('') + const childId = await ensureChildId() if (pendingDeviceId) { - await completeChildLink(pendingDeviceId, child.child_id) - Taro.showToast({ title: 'Child linked', icon: 'success' }) - goDevicePage() + if (!childId) { + throw new Error('请选择儿童或填写新儿童昵称') + } + + await setBindingChild(pendingDeviceId, { child_id: childId }) + setSelectedBindingDeviceId(pendingDeviceId) + Taro.showToast({ title: '绑定完成', icon: 'success' }) + setTimeout(() => { + goDevicePage() + }, 300) return } - const childRes = await getChildren() - setChildren(childRes.items || []) - Taro.showToast({ title: 'Child created', icon: 'success' }) - } catch (err) { - Taro.showToast({ title: 'Create child failed', icon: 'none' }) - } finally { - setSaving(false) - } - } + if (!deviceId.trim()) { + throw new Error('请输入设备号') + } + if (!serialNumber.trim()) { + throw new Error('请输入设备序列号') + } - const handleLogout = () => { - Taro.showModal({ - title: 'Logout', - content: 'Clear login info and go to login page?', - confirmColor: '#FF8C42', - success: (res) => { - if (!res.confirm) return - Taro.removeStorageSync('token') - Taro.removeStorageSync('user_id') - Taro.removeStorageSync('expires_in') - Taro.removeStorageSync('userInfo') - Taro.removeStorageSync('hasDevice') - Taro.removeStorageSync('deviceInfo') - Taro.reLaunch({ url: '/pages/login/index' }) - }, - }) + const result = await directBind({ + device_id: deviceId.trim(), + serial_number: serialNumber.trim(), + ...(childId ? { child_id: childId } : {}), + }) + + setSelectedBindingDeviceId(result.device_id) + + if (childId || result.child_id) { + Taro.showToast({ title: '设备绑定成功', icon: 'success' }) + setTimeout(() => { + goDevicePage() + }, 300) + return + } + + setPendingDeviceId(result.device_id) + setDeviceId(result.device_id) + Taro.showToast({ + title: '设备已绑定,请继续关联儿童', + icon: 'none', + }) + } catch (error: any) { + console.error('[bind] submit failed:', error) + Taro.showToast({ + title: error?.message || '绑定失败,请重试', + icon: 'none', + }) + } finally { + Taro.hideLoading() + setSubmitting(false) + } } if (loading) { return ( - Loading... + + 加载中... + ) } @@ -203,8 +235,10 @@ export default function Bind() { return ( - Bind Device - Scan QR/device code to bind first, then set child profile. + {pendingDeviceId ? '补全绑定' : '绑定设备'} + + {pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'} + @@ -216,8 +250,8 @@ export default function Bind() { mode='aspectFit' /> - {isScanning ? 'Scanning...' : 'Tap to scan'} - Place QR/barcode in frame + {isScanning ? '正在扫码...' : '点击扫码'} + 请将二维码放入框内 @@ -228,51 +262,95 @@ export default function Bind() { - - - # - Manual device code input - {'>'} - - + + + 设备信息 - {pendingDeviceId && ( - - Device bound: {pendingDeviceId} - Add child profile to complete setup - + + 设备号 setNewChildName(e.detail.value)} + className='field-input' + value={deviceId} + disabled={!!pendingDeviceId} + placeholder='请输入设备号' + onInput={(event) => setDeviceId(event.detail.value)} /> - - {saving ? 'Saving...' : 'Create and link child'} + + + 设备序列号 + setSerialNumber(event.detail.value)} + /> + + {pendingDeviceId && ( + + 当前待完成设备:{pendingDeviceId} + + )} - )} + + + 儿童信息 + + {children.length > 0 && ( + + {children.map((child) => ( + { + setSelectedChildId((currentChildId) => (currentChildId === child.child_id ? null : child.child_id)) + setNewChildName('') + }} + > + {child.child_name} + + ))} + + )} + + + 新建儿童昵称 + 0 ? '不选已有儿童时,可填写新昵称' : '请输入儿童昵称'} + onInput={(event) => { + setNewChildName(event.detail.value) + if (event.detail.value) { + setSelectedChildId(null) + } + }} + /> + + + 优先选择已有儿童,也可以直接输入一个新昵称。 + + + + - Binding Tips + 绑定帮助 1 - Find QR code or device code on device. + 先扫描二维码,自动填入设备号和序列号。 2 - Bind device first by scan/manual input. + 如果二维码里只有设备号,请手动补充序列号。 3 - Create or select child profile to finish setup. - - - - - - Logout + 没有儿童资料时,可以直接在本页新建并完成绑定。 diff --git a/banban-mini/src/pages/chat/detail/index.scss b/banban-mini/src/pages/chat/detail/index.scss index 13588a2..d10d348 100644 --- a/banban-mini/src/pages/chat/detail/index.scss +++ b/banban-mini/src/pages/chat/detail/index.scss @@ -70,6 +70,7 @@ flex: 1; overflow-y: auto; padding: 24px; + box-sizing: border-box; } .time-divider { @@ -92,16 +93,33 @@ &.user { flex-direction: row-reverse; + + .message-main { + align-items: flex-end; + } } - &.ai { - .ai-avatar { + &.peer { + .peer-avatar { margin-right: 14px; flex-shrink: 0; } } } +.message-main { + display: flex; + flex-direction: column; + max-width: 76%; +} + +.message-meta { + display: block; + font-size: 22px; + color: #999999; + margin-bottom: 8px; +} + .user-avatar { width: 68px; height: 68px; @@ -117,7 +135,7 @@ } } -.ai-avatar { +.peer-avatar { width: 68px; height: 68px; background: #FEF3E8; @@ -132,7 +150,7 @@ } .message-bubble { - max-width: 68%; + max-width: 100%; padding: 18px 22px; font-size: 30px; line-height: 1.5; @@ -148,7 +166,7 @@ margin-right: 14px; } - .ai & { + .peer & { background: #FFFFFF; color: #1A1A1A; border-radius: 20px 20px 20px 6px; @@ -156,51 +174,53 @@ } } -.audio-message { +.empty-chat { + min-height: 320px; display: flex; align-items: center; + justify-content: center; +} - .audio-icon { - width: 48px; - height: 48px; - background: rgba(255, 255, 255, 0.3); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - color: inherit; - margin-right: 14px; - flex-shrink: 0; - transition: background 0.2s; +.empty-chat-text { + font-size: 28px; + color: #999999; +} - &.playing { - background: rgba(255, 255, 255, 0.6); - } +.composer { + background: #FFFFFF; + border-top: 1px solid #EEEEEE; + padding: 18px 20px calc(18px + env(safe-area-inset-bottom)); + display: flex; + align-items: center; + gap: 16px; +} - &.orange { - background: rgba(255, 140, 66, 0.2); - &.playing { - background: rgba(255, 140, 66, 0.4); - } - } - } +.composer-input { + flex: 1; + min-height: 76px; + background: #F5F7FA; + border-radius: 38px; + padding: 0 24px; + font-size: 28px; + color: #1A1A1A; +} - .audio-info { - display: flex; - flex-direction: column; +.composer-send { + min-width: 132px; + height: 76px; + border-radius: 38px; + background: #FF8C42; + display: flex; + align-items: center; + justify-content: center; - .audio-title { - font-size: 28px; - font-weight: 500; - color: inherit; - margin-bottom: 4px; - } - - .audio-duration { - font-size: 24px; - opacity: 0.8; - } + &.disabled { + opacity: 0.55; } } +.composer-send-text { + font-size: 28px; + color: #FFFFFF; + font-weight: 600; +} diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index bd58efd..9e8d4f5 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -1,83 +1,162 @@ -import { View, Text, ScrollView, Image } from '@tarojs/components' -import { useState, useEffect, useRef } from 'react' +import { View, Text, ScrollView, Image, Input } from '@tarojs/components' +import { useEffect, useState } from 'react' import Taro, { useRouter } from '@tarojs/taro' -import { getMessages, getCharacter, ChatMessage, Character } from '../../../services/chat' +import { + getMessages, + getCharacter, + sendParentMessage, + ChatMessage, + Character, + ConversationSource, + PeerKind, +} from '../../../services/chat' import './index.scss' +function normalizePeerKind(value?: string): PeerKind { + if (value === 'child' || value === 'parent' || value === 'ai') return value + return 'ai' +} + +function normalizeSource(value?: string): ConversationSource { + return value === 'im' ? 'im' : 'ai' +} + +function normalizeNumericId(value?: string): number | null { + const normalized = Number(value) + if (!Number.isFinite(normalized) || normalized <= 0) return null + return normalized +} + +function formatParticipantLabel(message: ChatMessage): string { + const senderType = message.senderType || 'unknown' + const senderId = message.senderId || '--' + const name = message.senderName || '' + + if (senderType === 'parent') { + return name === '我' ? '我' : name || '家长' + } + if (senderType === 'child') { + return name || `儿童ID ${senderId}` + } + if (senderType === 'device') { + return name || `设备ID ${senderId}` + } + if (senderType === 'ai') { + return name || 'AI' + } + return `${senderType} ${senderId}` +} + export default function ChatDetail() { const router = useRouter() - const { id } = router.params - const conversationId = parseInt(id || '1') + const params = router.params + const initialConversationId = normalizeNumericId(params.id) || 0 + const conversationSource = normalizeSource(params.source) + const conversationPeerKind = normalizePeerKind(params.peerKind) + const decodedRoleKey = decodeURIComponent(params.roleKey || '') + const decodedName = decodeURIComponent(params.name || '玩伴') + const decodedConversationTypeName = decodeURIComponent(params.conversationTypeName || '') + const decodedPeerId = decodeURIComponent(params.peerId || '') + const decodedDeviceId = decodeURIComponent(params.deviceId || '') + const decodedChildName = decodeURIComponent(params.childName || '') + const childId = normalizeNumericId(params.childId) + const parentUserId = normalizeNumericId(params.parentUserId) + const canSend = params.canSend === '1' + + const [activeConversationId, setActiveConversationId] = useState(initialConversationId) const [character, setCharacter] = useState({ - id: conversationId, - name: decodeURIComponent(router.params.name || 'AI 玩伴'), - description: '温柔音色 / 开朗耐心', - icon: '🎤' + id: initialConversationId, + name: decodedName, + description: '交流记录', + icon: '🤖', + source: conversationSource, + peerKind: conversationPeerKind, + conversationTypeName: decodedConversationTypeName, + childId, + childName: decodedChildName, + peerId: decodedPeerId, + parentUserId, + deviceId: decodedDeviceId, + channelLabel: '--', + canSend, }) - const [messages, setMessages] = useState([]) - const [playingId, setPlayingId] = useState(null) - const audioCtx = useRef(null) + const [loading, setLoading] = useState(true) + const [inputText, setInputText] = useState('') + const [sending, setSending] = useState(false) useEffect(() => { - loadChatData() - return () => { - // 页面卸载时销毁音频 - audioCtx.current?.destroy() + void loadChatData(activeConversationId) + }, [activeConversationId, conversationSource, conversationPeerKind]) + + const loadChatData = async (conversationId: number) => { + setLoading(true) + try { + const char = await getCharacter({ + conversationId, + source: conversationSource, + peerKind: conversationPeerKind, + name: decodedName, + roleKey: decodedRoleKey, + conversationTypeName: decodedConversationTypeName, + childId, + childName: decodedChildName, + peerId: decodedPeerId, + parentUserId, + deviceId: decodedDeviceId, + canSend, + }) + if (char) { + setCharacter(char) + Taro.setNavigationBarTitle({ title: char.name }) + } + const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName) + setMessages(msgs) + } catch (error: any) { + console.error('[chat-detail] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) + } finally { + setLoading(false) } - }, [conversationId]) - - const loadChatData = async () => { - const char = await getCharacter(conversationId) - if (char) { - setCharacter(char) - Taro.setNavigationBarTitle({ title: char.name }) - } - const msgs = await getMessages(conversationId) - setMessages(msgs) - } - - const handlePlayAudio = (msg: ChatMessage) => { - if (!msg.audioUrl) return - - // 如果点的是正在播放的那条,就停止 - if (playingId === msg.id) { - audioCtx.current?.stop() - audioCtx.current?.destroy() - audioCtx.current = null - setPlayingId(null) - return - } - - // 停掉上一条 - audioCtx.current?.stop() - audioCtx.current?.destroy() - - const ctx = Taro.createInnerAudioContext() - ctx.src = msg.audioUrl - ctx.autoplay = true - ctx.onPlay(() => setPlayingId(msg.id)) - ctx.onEnded(() => { - setPlayingId(null) - ctx.destroy() - audioCtx.current = null - }) - ctx.onError(() => { - setPlayingId(null) - ctx.destroy() - audioCtx.current = null - Taro.showToast({ title: '播放失败', icon: 'none' }) - }) - audioCtx.current = ctx } const handleBack = () => { - audioCtx.current?.stop() - audioCtx.current?.destroy() Taro.navigateBack() } + const handleSend = async () => { + const normalizedContent = inputText.trim() + if (!normalizedContent || sending) return + if (!character.canSend || !character.childId) { + Taro.showToast({ title: '当前会话不支持发送', icon: 'none' }) + return + } + + setSending(true) + try { + const response = await sendParentMessage(character.childId, normalizedContent) + setInputText('') + if (response.conversation_id && response.conversation_id !== activeConversationId) { + setActiveConversationId(response.conversation_id) + } else { + await loadChatData(activeConversationId) + } + Taro.showToast({ title: '已发送', icon: 'success' }) + } catch (error: any) { + console.error('[chat-detail] send failed:', error) + Taro.showToast({ + title: error?.message || '发送失败,请重试', + icon: 'none', + }) + } finally { + setSending(false) + } + } + return ( @@ -95,62 +174,72 @@ export default function ChatDetail() { - - {messages.map((msg, index) => ( - - {(index === 0 || messages[index - 1].type !== msg.type || - messages[index - 1].time !== msg.time) && ( - - {msg.time} - - )} - - {msg.type === 'user' ? ( - - - {msg.isAudio ? ( - handlePlayAudio(msg)}> - - {playingId === msg.id ? '⏸' : '▶'} - - - 录音文件 - {msg.audioDuration} - - - ) : ( - {msg.content} - )} - - - 👦 - - - ) : ( - - - {character.icon} - - - {msg.isAudio ? ( - handlePlayAudio(msg)}> - - {playingId === msg.id ? '⏸' : '▶'} - - - 录音文件 - {msg.audioDuration} - - - ) : ( - {msg.content} - )} - - - )} + + {loading ? ( + + 加载中... - ))} + ) : messages.length === 0 ? ( + + + {character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'} + + + ) : ( + messages.map((msg, index) => ( + + {(index === 0 || messages[index - 1].time !== msg.time) && ( + + {msg.time} + + )} + + {msg.type === 'user' ? ( + + + {formatParticipantLabel(msg)} + + {msg.content} + + + + {msg.senderType === 'parent' ? '👨' : '👦'} + + + ) : ( + + + {msg.senderType === 'child' ? '🧒' : character.icon} + + + {formatParticipantLabel(msg)} + + {msg.content} + + + + )} + + )) + )} + + {character.canSend && character.childId && ( + + setInputText(event.detail.value)} + onConfirm={handleSend} + /> + + {sending ? '发送中' : '发送'} + + + )} ) } diff --git a/banban-mini/src/pages/chat/index.scss b/banban-mini/src/pages/chat/index.scss index 78e3a17..663e425 100644 --- a/banban-mini/src/pages/chat/index.scss +++ b/banban-mini/src/pages/chat/index.scss @@ -14,15 +14,8 @@ font-weight: 700; color: #1A1A1A; display: block; - margin-bottom: 12px; line-height: 1.2; } - - .page-subtitle { - font-size: 28px; - color: #666666; - line-height: 1.4; - } } .conversation-list { @@ -74,19 +67,49 @@ .conversation-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; margin-bottom: 12px; - .conversation-name { - font-size: 32px; - font-weight: 600; - color: #1A1A1A; - } - .conversation-time { font-size: 24px; color: #999999; flex-shrink: 0; + margin-left: 16px; + } +} + +.conversation-name-wrap { + display: flex; + align-items: center; + flex-wrap: wrap; + min-width: 0; +} + +.conversation-name { + font-size: 32px; + font-weight: 600; + color: #1A1A1A; + margin-right: 12px; +} + +.conversation-tag { + height: 40px; + padding: 0 14px; + border-radius: 999px; + font-size: 22px; + line-height: 40px; + color: #FFFFFF; + + &.child { + background: #5B8FF9; + } + + &.parent { + background: #36CFC9; + } + + &.ai { + background: #FF8C42; } } @@ -106,6 +129,13 @@ } } +.conversation-meta { + display: block; + margin-top: 10px; + font-size: 24px; + color: #A0A0A0; +} + .unread-badge { min-width: 36px; height: 36px; @@ -123,3 +153,31 @@ font-weight: 600; } } + +.loading { + min-height: 50vh; + display: flex; + align-items: center; + justify-content: center; + + text { + font-size: 30px; + color: #999999; + } +} + +.empty-card { + background: #FFFFFF; + border-radius: 20px; + margin: 0 24px; + padding: 48px 32px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); + text-align: center; +} + +.empty-title { + display: block; + font-size: 34px; + font-weight: 700; + color: #1A1A1A; +} diff --git a/banban-mini/src/pages/chat/index.tsx b/banban-mini/src/pages/chat/index.tsx index 2565f93..a93fd6a 100644 --- a/banban-mini/src/pages/chat/index.tsx +++ b/banban-mini/src/pages/chat/index.tsx @@ -1,63 +1,102 @@ import { View, Text, ScrollView } from '@tarojs/components' -import { useState, useEffect } from 'react' -import Taro from '@tarojs/taro' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/auth' import { getConversations, ChatConversation } from '../../services/chat' import './index.scss' export default function Chat() { const [conversations, setConversations] = useState([]) + const [loading, setLoading] = useState(true) - useEffect(() => { - loadConversations() - }, []) + useDidShow(() => { + void loadConversations() + }) const loadConversations = async () => { - const data = await getConversations() - setConversations(data) + if (!getToken()) { + Taro.reLaunch({ url: '/pages/login/index' }) + return + } + + setLoading(true) + try { + const data = await getConversations() + setConversations(data) + } catch (error: any) { + console.error('[chat] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) + } finally { + setLoading(false) + } } const handleConversationClick = (conversation: ChatConversation) => { Taro.navigateTo({ - url: `/pages/chat/detail/index?id=${conversation.id}&name=${encodeURIComponent(conversation.name)}` + url: + `/pages/chat/detail/index?id=${conversation.id}` + + `&source=${conversation.source}` + + `&name=${encodeURIComponent(conversation.name)}` + + `&peerKind=${conversation.peerKind}` + + `&roleKey=${encodeURIComponent(conversation.roleKey || '')}` + + `&conversationTypeName=${encodeURIComponent(conversation.conversationTypeName || '')}` + + `&peerId=${encodeURIComponent(conversation.peerId || '')}` + + `&childId=${conversation.childId || ''}` + + `&childName=${encodeURIComponent(conversation.childName || '')}` + + `&parentUserId=${conversation.parentUserId || ''}` + + `&deviceId=${encodeURIComponent(conversation.deviceId || '')}` + + `&channelLabel=${encodeURIComponent(conversation.channelLabel || '')}` + + `&canSend=${conversation.canSend ? '1' : '0'}`, }) } return ( - AI 玩伴 - 选择玩伴开始对话 + 玩伴 - - {conversations.map((conversation) => ( - handleConversationClick(conversation)} - > - - {conversation.avatar} - - - - {conversation.name} - {conversation.time} + {loading ? ( + + 加载中... + + ) : conversations.length === 0 ? ( + + 暂无玩伴会话 + + ) : ( + + {conversations.map((conversation) => ( + handleConversationClick(conversation)} + > + + {conversation.avatar} - - - {conversation.lastMessage} - - {conversation.unreadCount && conversation.unreadCount > 0 && ( - - {conversation.unreadCount} + + + + {conversation.name} + {conversation.typeLabel} - )} + {conversation.time} + + + + {conversation.lastMessage} + + + {conversation.childName && {conversation.childName}} - - ))} - + ))} + + )} ) } diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss index 89dfc31..4cbf338 100644 --- a/banban-mini/src/pages/device/index.scss +++ b/banban-mini/src/pages/device/index.scss @@ -14,15 +14,8 @@ font-weight: 700; color: #1A1A1A; display: block; - margin-bottom: 12px; line-height: 1.2; } - - .page-subtitle { - font-size: 28px; - color: #666666; - line-height: 1.4; - } } .status-card { @@ -289,3 +282,99 @@ color: #999999; } } + +.detail-card { + background: #FFFFFF; + border-radius: 20px; + margin: 0 24px 24px; + padding: 10px 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid #F3F3F3; + + &:last-child { + border-bottom: none; + } +} + +.detail-label { + font-size: 28px; + color: #666666; +} + +.detail-value { + max-width: 60%; + font-size: 28px; + color: #1A1A1A; + text-align: right; + word-break: break-all; +} + +.empty-card { + background: #FFFFFF; + border-radius: 24px; + margin: 0 24px; + padding: 48px 32px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); + text-align: center; +} + +.empty-robot { + width: 132px; + height: 132px; + background: #FEF3E8; + border-radius: 50%; + margin: 0 auto 24px; + display: flex; + align-items: center; + justify-content: center; + + .robot-img { + width: 68px; + height: 68px; + } +} + +.empty-title { + display: block; + font-size: 34px; + font-weight: 700; + color: #1A1A1A; +} + +.empty-btn { + margin-top: 28px; + height: 92px; + border-radius: 16px; + background: #FF8C42; + display: flex; + align-items: center; + justify-content: center; +} + +.empty-btn-text { + font-size: 30px; + color: #FFFFFF; + font-weight: 600; +} + +.helper-card { + background: #FFFFFF; + border-radius: 20px; + margin: 0 24px 24px; + padding: 28px 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.helper-title { + display: block; + font-size: 30px; + font-weight: 600; + color: #1A1A1A; +} diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index e4bff30..492c70c 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -1,104 +1,65 @@ import { View, Text, Switch, Slider, Image } from '@tarojs/components' -import { useState, useEffect } from 'react' -import Taro from '@tarojs/taro' -import { getCurrentBinding } from '@/services/binding' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/auth' +import { Binding, resolveActiveBinding } from '@/services/binding' +import { Child, getChild } from '@/services/child' import './index.scss' -interface DeviceData { - id: string - name: string - status: 'online' | 'offline' - battery: number - daysLeft: number - sleepEnabled: boolean - sleepTime: string - volume: number -} - export default function Device() { - const [device, setDevice] = useState(null) + const [binding, setBinding] = useState(null) + const [child, setChild] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [hasDevice, setHasDevice] = useState(false) + const [sleepEnabled, setSleepEnabled] = useState(true) + const [volume, setVolume] = useState(50) - useEffect(() => { - console.log('[Device] Checking login status...') - const token = Taro.getStorageSync('token') - console.log('[Device] Token exists:', !!token) - - if (!token) { - console.log('[Device] No token, redirecting to login') - Taro.redirectTo({ url: '/pages/login/index' }) + useDidShow(() => { + void loadDeviceInfo() + }) + + const loadDeviceInfo = async () => { + if (!getToken()) { + Taro.reLaunch({ url: '/pages/login/index' }) return } - loadDeviceInfo() - }, []) - - const loadDeviceInfo = async () => { + setIsLoading(true) try { - const binding = await getCurrentBinding() - console.log('[Device] Got binding:', binding) - - if (binding) { - if (!binding.child_id) { - setHasDevice(false) - Taro.showToast({ title: 'Please link child profile first', icon: 'none' }) - setTimeout(() => { - Taro.redirectTo({ url: '/pages/bind/index' }) - }, 600) - return - } + const activeBinding = await resolveActiveBinding() + setBinding(activeBinding) - setHasDevice(true) - Taro.setStorageSync('hasDevice', '1') - Taro.setStorageSync('deviceInfo', { - id: binding.device_id, - name: '伴伴设备', - status: 'online', - battery: 82 - }) - setDevice({ - id: binding.device_id, - name: '伴伴设备', - status: 'online', - battery: 82, - daysLeft: 4, - sleepEnabled: true, - sleepTime: '每天 22:00 - 07:00', - volume: 50 - }) + if (activeBinding?.child_id) { + try { + const currentChild = await getChild(activeBinding.child_id) + setChild(currentChild) + } catch (error) { + console.error('[device] load child failed:', error) + setChild(null) + } } else { - setHasDevice(false) - Taro.removeStorageSync('hasDevice') - Taro.removeStorageSync('deviceInfo') - } - } catch (err: any) { - console.log('[Device] No binding or error:', err?.message || err) - if (err?.status === 404) { - setHasDevice(false) - Taro.removeStorageSync('hasDevice') - Taro.removeStorageSync('deviceInfo') + setChild(null) } + } catch (error: any) { + console.error('[device] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) + } finally { + setIsLoading(false) } - setIsLoading(false) } const handleSleepToggle = (e: any) => { - if (!device) return - setDevice({ ...device, sleepEnabled: e.detail.value }) + setSleepEnabled(e.detail.value) Taro.showToast({ - title: e.detail.value ? '已开启定时休眠' : '已关闭定时休眠', - icon: 'success' + title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭', + icon: 'none', }) } const handleVolumeChange = (e: any) => { - if (!device) return - setDevice({ ...device, volume: e.detail.value }) - } - - const switchTab = (url: string) => { - Taro.switchTab({ url }) + setVolume(e.detail.value) } if (isLoading) { @@ -111,27 +72,48 @@ export default function Device() { ) } - // 未绑定设备,跳转到绑定页面 - if (!hasDevice || !device) { - Taro.redirectTo({ url: '/pages/bind/index' }) - return null + if (!binding) { + return ( + + + 设备首页 + + + + + + + 先绑定一台伴伴设备 + Taro.navigateTo({ url: '/pages/bind/index' })}> + 去绑定设备 + + + + ) } + const childName = child?.child_name || '未设置' + const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备' + const battery = 82 + const daysLeft = 4 + return ( - {device.name} - 设备运行状态良好 + {pageTitle} - 4G 在线 + {binding.child_id ? '已完成绑定' : '待关联儿童'} - {device.battery}% - 剩余电量 (预计续航 {device.daysLeft} 天) + + {battery} + % + + 模拟电量展示 (预计续航 {daysLeft} 天) @@ -140,6 +122,21 @@ export default function Device() { + + + 设备号 + {binding.device_id} + + + 儿童昵称 + {childName} + + + 绑定时间 + {binding.bound_at || '--'} + + + 基础控制 @@ -153,14 +150,10 @@ export default function Device() { 定时休眠 - {device.sleepTime} + 开启后进入休眠展示 - + @@ -188,7 +181,7 @@ export default function Device() { className='slider' min={0} max={100} - value={device.volume} + value={volume} onChange={handleVolumeChange} activeColor='#FF8C42' backgroundColor='#E5E5E5' @@ -203,6 +196,11 @@ export default function Device() { + {!binding.child_id && ( + Taro.navigateTo({ url: '/pages/bind/index' })}> + 继续完成儿童关联 + + )} ) } diff --git a/banban-mini/src/pages/location/index.scss b/banban-mini/src/pages/location/index.scss index 576a317..5edceae 100644 --- a/banban-mini/src/pages/location/index.scss +++ b/banban-mini/src/pages/location/index.scss @@ -14,17 +14,42 @@ font-weight: 700; color: #1A1A1A; display: block; - margin-bottom: 12px; line-height: 1.2; } +} - .page-subtitle { - font-size: 28px; - color: #666666; - line-height: 1.4; +.mode-switch { + margin: 0 24px 20px; + padding: 8px; + background: #FFFFFF; + border-radius: 18px; + display: flex; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.mode-chip { + flex: 1; + height: 68px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + + &.active { + background: #FF8C42; + + .mode-chip-text { + color: #FFFFFF; + font-weight: 600; + } } } +.mode-chip-text { + font-size: 26px; + color: #666666; +} + .map-section { height: 50vh; position: relative; @@ -36,29 +61,6 @@ height: 100%; } -.location-marker { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -100%); - z-index: 5; -} - -.marker-pin { - width: 72px; - height: 72px; - background: #FF8C42; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 6px 20px rgba(255, 140, 66, 0.4); - - .marker-emoji { - font-size: 36px; - } -} - .location-card { background: #FFFFFF; border-radius: 20px; @@ -127,12 +129,18 @@ font-size: 32px; margin-right: 18px; flex-shrink: 0; + + &.selected { + background: #FFF0E6; + color: #FF8C42; + } } .location-info { flex: 1; - .location-address { + .location-address, + .location-coordinate { font-size: 30px; font-weight: 600; color: #1A1A1A; @@ -148,3 +156,34 @@ } } } + +.location-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.selected-point-card { + border: 2px solid #FFD0B1; +} + +.location-empty { + background: #F8F9FA; + border-radius: 16px; + padding: 28px 24px; +} + +.location-empty-title { + display: block; + font-size: 30px; + font-weight: 600; + color: #1A1A1A; + margin-bottom: 10px; +} + +.location-empty-desc { + display: block; + font-size: 26px; + color: #666666; + line-height: 1.5; +} diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index 7e0474f..207aa17 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -1,82 +1,285 @@ import { View, Text, Map } from '@tarojs/components' import { useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/auth' +import { + getCurrentDeviceLocation, + getDeviceTrajectory, + DeviceLocation, + DeviceTrajectoryPoint, +} from '@/services/location' import './index.scss' -interface LocationInfo { - address: string - updateTime: string - locationType: string +type TrajectoryMode = 'current' | 'today' | 'recent' + +const DEVICE_MARKER_ID = 1 +const TRAJECTORY_MARKER_BASE_ID = 1000 +const DEVICE_MARKER_ICON = require('../../assets/tab-icons/location-selected.png') +const START_MARKER_ICON = require('../../assets/tab-icons/device-icon.png') +const END_MARKER_ICON = require('../../assets/tab-icons/device-selected.png') +const POINT_MARKER_ICON = require('../../assets/tab-icons/location-icon.png') + +const DEFAULT_COORDINATES = { + latitude: 39.9042, + longitude: 116.4074, } -interface Coordinates { - latitude: number - longitude: number +const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [ + { key: 'current', label: '当前位置' }, + { key: 'today', label: '今日轨迹' }, + { key: 'recent', label: '最近点位' }, +] + +function formatTime(value?: string | null): string { + if (!value) return '--' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hour = `${date.getHours()}`.padStart(2, '0') + const minute = `${date.getMinutes()}`.padStart(2, '0') + return `${month}-${day} ${hour}:${minute}` } -// 模拟接口:获取设备位置(返回随机经纬度) -const fetchDeviceLocation = async (): Promise => { - // 模拟网络延迟 - await new Promise(resolve => setTimeout(resolve, 800)) +function formatAccuracy(value?: number | null): string { + if (value === undefined || value === null) return '未知' + return `${value} 米` +} - // 在北京范围内生成随机坐标(大致范围) - const baseLat = 39.9042 - const baseLng = 116.4074 - const randomOffset = () => (Math.random() - 0.5) * 0.1 // 约 ±5km 的偏移 +function getTodayStartISOString(): string { + const now = new Date() + now.setHours(0, 0, 0, 0) + return now.toISOString() +} - return { - latitude: baseLat + randomOffset(), - longitude: baseLng + randomOffset() - } +function getRecentStartISOString(hours = 24): string { + return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString() +} + +function getMarkerLabel(point: DeviceTrajectoryPoint | DeviceLocation | null): string { + if (!point) return '' + return point.child_name || point.device_id +} + +function getTrajectoryMarkerId(index: number): number { + return TRAJECTORY_MARKER_BASE_ID + index } export default function Location() { - const [location, setLocation] = useState({ - address: '阳光社区 3期 附近', - updateTime: '刚刚', - locationType: '网络定位' - }) + const [loading, setLoading] = useState(true) + const [deviceLocation, setDeviceLocation] = useState(null) + const [trajectory, setTrajectory] = useState([]) + const [selectedPoint, setSelectedPoint] = useState(null) + const [trajectoryMode, setTrajectoryMode] = useState('current') + const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES) - const [coordinates, setCoordinates] = useState({ - latitude: 39.9042, - longitude: 116.4074 - }) - - // 每次页面显示时获取最新位置 useDidShow(() => { - loadLocation() + void loadLocation() }) - const loadLocation = async () => { - try { - const coords = await fetchDeviceLocation() - setCoordinates(coords) - } catch (error) { - console.error('获取位置失败:', error) + const loadLocation = async (showToast = false, nextMode?: TrajectoryMode) => { + const targetMode = nextMode || trajectoryMode + + if (!getToken()) { + Taro.reLaunch({ url: '/pages/login/index' }) + return } - } - const refreshLocation = async () => { - Taro.showToast({ title: '正在刷新位置...', icon: 'loading' }) + if (showToast) { + Taro.showLoading({ title: '刷新中...' }) + } + + setLoading(true) + if (nextMode) { + setTrajectoryMode(nextMode) + setSelectedPoint(null) + } try { - const coords = await fetchDeviceLocation() - setCoordinates(coords) - setLocation({ - ...location, - updateTime: '刚刚' + const currentLocation = await getCurrentDeviceLocation() + setDeviceLocation(currentLocation) + + const nextCoordinates = currentLocation + ? { + latitude: currentLocation.lat, + longitude: currentLocation.lng, + } + : DEFAULT_COORDINATES + + let points: DeviceTrajectoryPoint[] = [] + if (targetMode === 'today') { + const response = await getDeviceTrajectory({ + startAt: getTodayStartISOString(), + limit: 200, + }) + points = response?.items || [] + } else if (targetMode === 'recent') { + const response = await getDeviceTrajectory({ + startAt: getRecentStartISOString(72), + limit: 100, + }) + points = response?.items || [] + } + + setTrajectory(points) + setSelectedPoint(points.length > 0 ? points[points.length - 1] : null) + + const focusPoint = points.length > 0 ? points[points.length - 1] : currentLocation + if (focusPoint) { + setCoordinates({ + latitude: focusPoint.lat, + longitude: focusPoint.lng, + }) + } else { + setCoordinates(nextCoordinates) + } + + if (showToast) { + Taro.showToast({ + title: currentLocation || points.length > 0 ? '位置已更新' : '暂无设备位置', + icon: currentLocation || points.length > 0 ? 'success' : 'none', + }) + } + } catch (error: any) { + console.error('[location] load failed:', error) + Taro.showToast({ + title: error?.message || '位置更新失败', + icon: 'none', }) - Taro.showToast({ title: '位置已更新', icon: 'success' }) - } catch (error) { - Taro.showToast({ title: '位置更新失败', icon: 'none' }) + } finally { + if (showToast) { + Taro.hideLoading() + } + setLoading(false) } } + const handleMarkerTap = (event: any) => { + const markerId = Number(event?.detail?.markerId) + if (markerId === DEVICE_MARKER_ID && deviceLocation) { + setCoordinates({ + latitude: deviceLocation.lat, + longitude: deviceLocation.lng, + }) + return + } + + const pointIndex = markerId - TRAJECTORY_MARKER_BASE_ID + const point = trajectory[pointIndex] + if (!point) return + + setSelectedPoint(point) + setCoordinates({ + latitude: point.lat, + longitude: point.lng, + }) + } + + const currentMarker = + deviceLocation && trajectoryMode === 'current' + ? [ + { + id: DEVICE_MARKER_ID, + latitude: deviceLocation.lat, + longitude: deviceLocation.lng, + title: getMarkerLabel(deviceLocation), + iconPath: DEVICE_MARKER_ICON, + width: 40, + height: 40, + callout: { + content: `当前位置\n${formatTime(deviceLocation.device_time)}`, + color: '#1A1A1A', + fontSize: 13, + anchorX: 0, + anchorY: -4, + borderRadius: 8, + borderWidth: 1, + borderColor: '#FF8C42', + bgColor: '#FFFFFF', + padding: 8, + display: 'BYCLICK' as const, + textAlign: 'center' as const, + }, + }, + ] + : [] + + const trajectoryMarkers = + trajectory.length > 0 + ? trajectory.map((point, index) => { + const isFirst = index === 0 + const isLast = index === trajectory.length - 1 + const isSelected = selectedPoint?.id === point.id + return { + id: getTrajectoryMarkerId(index), + latitude: point.lat, + longitude: point.lng, + title: `${isFirst ? '起点' : isLast ? '终点' : '轨迹点'} ${formatTime(point.device_time)}`, + iconPath: isFirst ? START_MARKER_ICON : isLast ? END_MARKER_ICON : POINT_MARKER_ICON, + width: isSelected ? 36 : isFirst || isLast ? 32 : 22, + height: isSelected ? 36 : isFirst || isLast ? 32 : 22, + zIndex: isSelected ? 20 : isFirst || isLast ? 12 : 8, + callout: { + content: `${isFirst ? '起点' : isLast ? '终点' : '轨迹点'}\n${formatTime(point.device_time)}`, + color: '#1A1A1A', + fontSize: 13, + anchorX: 0, + anchorY: -4, + borderRadius: 8, + borderWidth: 1, + borderColor: isSelected ? '#FF8C42' : '#E6E6E6', + bgColor: '#FFFFFF', + padding: 8, + display: isSelected ? ('ALWAYS' as const) : ('BYCLICK' as const), + textAlign: 'center' as const, + }, + } + }) + : [] + + const markers = trajectoryMode === 'current' ? currentMarker : trajectoryMarkers + + const polyline = + trajectory.length >= 2 + ? [ + { + points: trajectory.map((item) => ({ + latitude: item.lat, + longitude: item.lng, + })), + color: '#FF8C42', + width: 6, + dottedLine: false, + }, + ] + : [] + + const summaryTitle = + trajectoryMode === 'current' + ? '当前设备坐标' + : trajectoryMode === 'today' + ? '今日轨迹' + : '最近点位' + return ( 设备定位 - 基于运营商网络基站定位 + + + + {MODE_OPTIONS.map((item) => ( + { + if (trajectoryMode === item.key) return + void loadLocation(false, item.key) + }} + > + {item.label} + + ))} @@ -85,43 +288,106 @@ export default function Location() { latitude={coordinates.latitude} longitude={coordinates.longitude} scale={15} - markers={[{ - id: 1, - latitude: coordinates.latitude, - longitude: coordinates.longitude, - title: '设备位置', - width: 40, - height: 40 - }]} - showLocation + markers={markers} + polyline={polyline} + onMarkerTap={handleMarkerTap} + onCalloutTap={handleMarkerTap} + onError={(event) => { + console.error('[location] map error:', event.detail) + }} /> - - - 🤖 - - - 当前大致位置 - 更新于 {location.updateTime} ({location.locationType}) + {summaryTitle} + + {loading + ? '正在获取设备位置...' + : trajectoryMode === 'current' + ? `更新于 ${formatTime(deviceLocation?.updated_at)}` + : `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`} + - + loadLocation(true)}> - - - 📍 + {trajectoryMode === 'current' ? ( + deviceLocation ? ( + + + 📍 + + + + {deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)} + + + 设备 {deviceLocation.device_id} · {deviceLocation.child_name || '未命名儿童'} + + 上报时间 {formatTime(deviceLocation.device_time)} + + 坐标系 {deviceLocation.coord_type} · 精度 {formatAccuracy(deviceLocation.accuracy_m)} + + {deviceLocation.battery_pct !== null && deviceLocation.battery_pct !== undefined && ( + 设备电量 {deviceLocation.battery_pct}% + )} + + + ) : ( + + 暂无设备位置 + + ) + ) : trajectory.length > 0 ? ( + + {selectedPoint && ( + + + + + + + {selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)} + + 上报时间 {formatTime(selectedPoint.device_time)} + + 坐标系 {selectedPoint.coord_type} · 精度 {formatAccuracy(selectedPoint.accuracy_m)} + + {selectedPoint.battery_pct !== null && selectedPoint.battery_pct !== undefined && ( + 设备电量 {selectedPoint.battery_pct}% + )} + + + )} + + + + 🛣️ + + + 共 {trajectory.length} 个轨迹点 + + 起点 {formatTime(trajectory[0].device_time)} · {trajectory[0].lat.toFixed(6)}, {trajectory[0].lng.toFixed(6)} + + + 终点 {formatTime(trajectory[trajectory.length - 1].device_time)} ·{' '} + {trajectory[trajectory.length - 1].lat.toFixed(6)}, {trajectory[trajectory.length - 1].lng.toFixed(6)} + + + - - {location.address} - 通过设备 4G 信号基站解析获得,误差范围约 50-200 米。 + ) : ( + + 暂无轨迹 + + {trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'} + - + )} ) diff --git a/banban-mini/src/pages/login/index.tsx b/banban-mini/src/pages/login/index.tsx index 694eee3..f0b98c8 100644 --- a/banban-mini/src/pages/login/index.tsx +++ b/banban-mini/src/pages/login/index.tsx @@ -1,118 +1,75 @@ import { View, Text, Button } from '@tarojs/components' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import Taro from '@tarojs/taro' -import { BASE_URL, request } from '@/services/api' +import { getToken, wechatLogin } from '@/services/auth' import './index.scss' export default function Login() { + const [submitting, setSubmitting] = useState(false) + useEffect(() => { - console.log('[Login] Page mounted, checking token...') - const token = Taro.getStorageSync('token') - console.log('[Login] Token exists:', !!token) - if (token) { - console.log('[Login] Already logged in, redirecting to device page') + if (getToken()) { Taro.reLaunch({ url: '/pages/device/index' }) } }, []) - const doWeChatLogin = async () => { + const handleLogin = async () => { + if (submitting) return + + setSubmitting(true) + Taro.showLoading({ title: '登录中...' }) + try { - console.log('[Login] Starting WeChat login...') - Taro.showLoading({ title: '登录中...' }) - - // 1. 调用微信登录获取 code - console.log('[Login] Step 1: Calling Taro.login()...') const loginRes = await Taro.login() - const code = loginRes.code - console.log('[Login] Got code:', code ? 'yes' : 'no') - - // 2. 获取用户头像信息 (可选) - let userInfo = null - try { - console.log('[Login] Step 2: Calling Taro.getUserProfile()...') - const profileRes = await Taro.getUserProfile({ - desc: '用于完善用户资料' - }) - userInfo = profileRes.userInfo - console.log('[Login] Got userInfo:', !!userInfo) - } catch (e) { - console.log('[Login] getUserProfile failed (expected if user denied):', e) + if (!loginRes.code) { + throw new Error('未获取到微信登录凭证') } - // 3. 调用后端登录接口 (微信登录) - console.log('[Login] Step 3: Calling backend /auth/login...') - const res = await request<{ access_token: string; expires_in: number; user_id: number }>('/auth/login', { - method: 'POST', - data: { - code: code, // 微信 code - nickname: userInfo?.nickName, - avatar_url: userInfo?.avatarUrl - } - }) - console.log('[Login] Backend login success, user_id:', res.user_id) + let userInfo: + | { + nickName?: string + avatarUrl?: string + } + | null = null + + try { + const profileRes = await Taro.getUserProfile({ + desc: '用于完善家长资料展示', + }) + userInfo = profileRes.userInfo || null + } catch (error) { + console.log('[login] getUserProfile skipped:', error) + } + + await wechatLogin({ + code: loginRes.code, + nickname: userInfo?.nickName, + avatar_url: userInfo?.avatarUrl, + }) - // 4. 保存 token 和用户信息 - console.log('[Login] Step 4: Saving token and user info...') - Taro.setStorageSync('token', res.access_token) - Taro.setStorageSync('user_id', res.user_id) - Taro.setStorageSync('expires_in', res.expires_in) - - // 保存用户信息用于显示 if (userInfo) { Taro.setStorageSync('userInfo', { nickname: userInfo.nickName, - avatar_url: userInfo.avatarUrl - }) - } - - // 5. 如果获取到了用户信息,发送到后端更新 - if (userInfo) { - console.log('[Login] Step 5: Updating user profile...') - try { - await request(`/parents/${res.user_id}`, { - method: 'PATCH', - data: { - nickname: userInfo.nickName, - avatar_url: userInfo.avatarUrl - } - }) - } catch (e) { - console.log('[Login] Update user profile failed:', e) - } - } - - Taro.hideLoading() - console.log('[Login] Step 6: Login complete, showing success toast') - Taro.showToast({ title: '登录成功', icon: 'success' }) - - // 6. 跳转到首页 - console.log('[Login] Redirecting to /pages/device/index...') - setTimeout(() => { - Taro.reLaunch({ url: '/pages/device/index' }) - }, 500) - - } catch (err: any) { - Taro.hideLoading() - console.error('[Login] Login failed:', err) - - // 如果是 401 错误,尝试通过创建新用户登录 - if (err.status === 401) { - Taro.showToast({ - title: '登录失败,请重试', - icon: 'none' + avatar_url: userInfo.avatarUrl, }) } else { - Taro.showToast({ - title: err.message || '网络错误,请重试', - icon: 'none' - }) + Taro.removeStorageSync('userInfo') } - } - } - const handleLogin = () => { - console.log('Button clicked, starting login...') - doWeChatLogin() + Taro.showToast({ title: '登录成功', icon: 'success' }) + setTimeout(() => { + Taro.reLaunch({ url: '/pages/device/index' }) + }, 300) + } catch (error: any) { + console.error('[login] failed:', error) + Taro.showToast({ + title: error?.message || '登录失败,请重试', + icon: 'none', + }) + } finally { + Taro.hideLoading() + setSubmitting(false) + } } return ( @@ -142,12 +99,9 @@ export default function Login() { - diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index 9bba1ba..b9d7829 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -161,149 +161,198 @@ } } -.add-input-box { +.image-avatar { + width: 100%; + height: 100%; + border-radius: 24px; +} + +.summary-card { background: #FFFFFF; border-radius: 20px; margin: 0 24px 24px; - padding: 24px; + padding: 10px 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} - .input-row { - display: flex; - flex-direction: column; - gap: 16px; +.summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid #F2F2F2; + + &:last-child { + border-bottom: none; } +} - .input-wrapper { - .name-input { - background: #F5F7FA; - border-radius: 12px; - padding: 20px 24px; - font-size: 30px; - width: 100%; - box-sizing: border-box; - } +.summary-label { + font-size: 28px; + color: #666666; +} - .input-hint { - font-size: 24px; - color: #999999; - margin-top: 12px; - display: block; - } - } - - .input-btns { - display: flex; - justify-content: flex-end; - gap: 24px; - - .confirm-btn { - font-size: 30px; - color: #FF8C42; - font-weight: 500; - } - - .cancel-btn { - font-size: 30px; - color: #999999; - } - } +.summary-value { + max-width: 60%; + font-size: 28px; + color: #1A1A1A; + text-align: right; + word-break: break-all; } .menu-item.disabled { - opacity: 0.5; - - &:active { - background: transparent; - } + opacity: 0.45; } .logout-section { - margin: 24px; + margin: 0 24px; } .logout-btn { + height: 92px; + border-radius: 18px; background: #FFFFFF; - border-radius: 20px; - padding: 26px 24px; - text-align: center; + display: flex; + align-items: center; + justify-content: center; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} - .logout-text { - font-size: 30px; - color: #FF3B30; - font-weight: 500; - } +.logout-text { + font-size: 30px; + color: #FF6B35; + font-weight: 600; } .modal-mask { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); + inset: 0; + background: rgba(0, 0, 0, 0.42); display: flex; align-items: center; justify-content: center; - z-index: 1000; + padding: 0 48px; + z-index: 20; } -.modal-content { +.modal-card { + width: 100%; background: #FFFFFF; - border-radius: 20px; - width: 600px; - margin: 0 48px; - overflow: hidden; + border-radius: 24px; + padding: 32px; + box-sizing: border-box; } -.modal-header { - padding: 32px 32px 16px; - text-align: center; +.modal-title { + display: block; + font-size: 32px; + font-weight: 700; + color: #1A1A1A; + margin-bottom: 24px; +} - .modal-title { - font-size: 34px; +.modal-input-wrap { + background: #F7F8FA; + border-radius: 16px; + padding: 0 24px; +} + +.modal-input { + height: 88px; + width: 100%; + font-size: 28px; + color: #1A1A1A; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + margin-top: 28px; +} + +.modal-action { + font-size: 30px; + margin-left: 32px; + + &.cancel { + color: #999999; + } + + &.confirm { + color: #FF8C42; font-weight: 600; - color: #1A1A1A; } } -.modal-body { - padding: 16px 32px 32px; +.device-switch-card { + max-height: 70vh; +} + +.device-switch-empty { + display: block; + font-size: 28px; + color: #999999; + line-height: 1.6; +} + +.device-switch-list { + max-height: 52vh; + overflow-y: auto; +} + +.device-switch-item { + padding: 24px; + border-radius: 18px; + background: #F7F8FA; + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + + &.active { + background: #FFF2E7; + border: 2px solid #FFBE94; + } +} + +.device-switch-head { display: flex; align-items: center; - - .modal-input { - background: #F5F7FA; - border-radius: 12px; - padding: 0 24px; - font-size: 34px; - width: 100%; - height: 96px; - box-sizing: border-box; - display: flex; - align-items: center; - } + justify-content: space-between; + margin-bottom: 10px; } -.modal-footer { +.device-switch-id { + font-size: 28px; + color: #1A1A1A; + font-weight: 600; + word-break: break-all; +} + +.device-switch-tag { + margin-left: 16px; + padding: 6px 14px; + border-radius: 999px; + background: #FF8C42; + font-size: 22px; + color: #FFFFFF; + flex-shrink: 0; +} + +.device-switch-name { + font-size: 26px; + color: #666666; +} + +.loading { + min-height: 100%; display: flex; - border-top: 1px solid #F0F0F0; + align-items: center; + justify-content: center; - .modal-btn { - flex: 1; - text-align: center; - padding: 24px 0; + text { font-size: 32px; - - &.cancel { - color: #666666; - border-right: 1px solid #F0F0F0; - } - - &.confirm { - color: #FF8C42; - font-weight: 500; - } + color: #999999; } } diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index e93c8f3..a788c25 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -1,210 +1,270 @@ import { View, Text, Image, Input } from '@tarojs/components' -import { useState, useEffect } from 'react' -import Taro from '@tarojs/taro' -import { getToken, getCurrentUserId } from '@/services/api' -import { getChildren, createChild, updateChild } from '@/services/child' -import { getCurrentBinding, unbindDevice } from '@/services/binding' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { clearToken, getCurrentUserId, getToken } from '@/services/auth' +import { + BindingListItem, + clearSelectedBindingDeviceId, + getBindings, + resolveBindingSelection, + setBindingChild, + setSelectedBindingDeviceId, + unbindDevice, +} from '@/services/binding' +import { Child, createChild, getChildren, updateChild } from '@/services/child' import './index.scss' -interface Child { - child_id: number - child_name: string - child_gender: number - status: number -} - -interface Binding { - device_id: string - child_id: number | null - status: number -} - interface MenuItem { icon: string iconBgClass: string name: string - value?: string + value: string arrow?: boolean disabled?: boolean } export default function Sleep() { - const [children, setChildren] = useState([]) - const [binding, setBinding] = useState(null) const [loading, setLoading] = useState(true) + const [children, setChildren] = useState([]) + const [bindings, setBindings] = useState([]) + const [binding, setBinding] = useState(null) const [showModal, setShowModal] = useState(false) + const [showDeviceModal, setShowDeviceModal] = useState(false) const [modalType, setModalType] = useState<'add' | 'edit'>('add') const [childName, setChildName] = useState('') const [editingChildId, setEditingChildId] = useState(null) const [parentInfo, setParentInfo] = useState<{ nickname?: string; avatar_url?: string }>({}) - const token = getToken() - const userId = getCurrentUserId() + useDidShow(() => { + void loadData() + }) - useEffect(() => { - if (!token) { + const loadData = async () => { + if (!getToken()) { Taro.reLaunch({ url: '/pages/login/index' }) return } - loadData() - }, []) - const loadData = async () => { + setLoading(true) try { - setLoading(true) - - // Get parent info - const userInfo = Taro.getStorageSync('userInfo') || {} - setParentInfo(userInfo) - - // Get children - const childRes = await getChildren() - setChildren(childRes.items || []) + const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)]) + const bindingItems = bindingResponse.items || [] + const activeBinding = resolveBindingSelection(bindingItems) - // Get binding - try { - const bindRes = await getCurrentBinding() - setBinding(bindRes) - } catch (err: any) { - if (err.status === 404) { - setBinding(null) - } - } - } catch (err) { - console.error('Load failed:', err) + setChildren(childResponse.items || []) + setBindings(bindingItems) + setBinding(activeBinding) + setParentInfo(Taro.getStorageSync('userInfo') || {}) + } catch (error: any) { + console.error('[manage] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) } finally { setLoading(false) } } + const currentChild = binding?.child_id ? children.find((item) => item.child_id === binding.child_id) || null : null + const currentChildName = currentChild?.child_name || binding?.child_name || (binding && !binding.child_id ? '待关联' : '未设置') + const handleOpenModal = (type: 'add' | 'edit', child?: Child) => { setModalType(type) - if (type === 'edit' && child) { - setChildName(child.child_name) - setEditingChildId(child.child_id) - } else { - setChildName('') - setEditingChildId(null) - } + setChildName(child?.child_name || '') + setEditingChildId(child?.child_id || null) setShowModal(true) } - const handleModalConfirm = async () => { - if (!childName.trim()) { - Taro.showToast({ title: '请输入昵称', icon: 'none' }) - return - } - - try { - if (modalType === 'add') { - await createChild({ child_name: childName.trim() }) - Taro.showToast({ title: '添加成功', icon: 'success' }) - } else if (editingChildId) { - await updateChild(editingChildId, { child_name: childName.trim() }) - Taro.showToast({ title: '修改成功', icon: 'success' }) - } - setShowModal(false) - setChildName('') - setEditingChildId(null) - loadData() - } catch (err) { - Taro.showToast({ title: '操作失败', icon: 'none' }) - } - } - - const handleModalCancel = () => { + const handleCloseModal = () => { setShowModal(false) setChildName('') setEditingChildId(null) } - const handleUnbind = () => { - if (!binding) return - Taro.showModal({ - title: '解除设备绑定', - content: '确定要解除当前设备的绑定吗?', - confirmColor: '#FF8C42', - success: async (res) => { - if (res.confirm) { - try { - await unbindDevice(binding.device_id) - Taro.showToast({ title: '已解绑', icon: 'success' }) - setBinding(null) - loadData() - } catch (err) { - Taro.showToast({ title: '解绑失败', icon: 'none' }) - } - } - } + const handleSelectBinding = (targetBinding: BindingListItem) => { + setSelectedBindingDeviceId(targetBinding.device_id) + setBinding(targetBinding) + setShowDeviceModal(false) + Taro.showToast({ + title: '已切换设备', + icon: 'success', }) } - const handleLogout = async () => { + const handleSubmitModal = async () => { + const normalizedName = childName.trim() + if (!normalizedName) { + Taro.showToast({ title: '请输入儿童昵称', icon: 'none' }) + return + } + + try { + if (modalType === 'add') { + const createdChild = await createChild({ child_name: normalizedName }) + if (binding && !binding.child_id) { + await setBindingChild(binding.device_id, { child_id: createdChild.child_id }) + Taro.showToast({ title: '已创建并关联', icon: 'success' }) + } else { + Taro.showToast({ title: '创建成功', icon: 'success' }) + } + } else if (editingChildId) { + await updateChild(editingChildId, { child_name: normalizedName }) + Taro.showToast({ title: '修改成功', icon: 'success' }) + } + + handleCloseModal() + await loadData() + } catch (error: any) { + console.error('[manage] save child failed:', error) + Taro.showToast({ + title: error?.message || '保存失败,请重试', + icon: 'none', + }) + } + } + + const handleChildMenu = () => { + if (binding && !binding.child_id && children.length > 0) { + Taro.showActionSheet({ + itemList: [...children.map((item) => item.child_name), '新建儿童资料'], + success: async (result) => { + if (result.tapIndex === children.length) { + handleOpenModal('add') + return + } + + const targetChild = children[result.tapIndex] + if (!targetChild) return + + try { + await setBindingChild(binding.device_id, { child_id: targetChild.child_id }) + Taro.showToast({ title: '关联成功', icon: 'success' }) + await loadData() + } catch (error: any) { + Taro.showToast({ + title: error?.message || '关联失败,请重试', + icon: 'none', + }) + } + }, + }) + return + } + + if (currentChild) { + handleOpenModal('edit', currentChild) + return + } + + handleOpenModal('add') + } + + const handleUnbind = () => { + if (!binding) return + + Taro.showModal({ + title: '解除设备绑定', + content: '确定要解除当前设备绑定吗?', + confirmColor: '#FF8C42', + success: async (result) => { + if (!result.confirm) return + + try { + await unbindDevice(binding.device_id) + Taro.showToast({ title: '已解绑', icon: 'success' }) + await loadData() + } catch (error: any) { + Taro.showToast({ + title: error?.message || '解绑失败,请重试', + icon: 'none', + }) + } + }, + }) + } + + const handleLogout = () => { Taro.showModal({ title: '退出登录', - content: '确定要退出登录吗?', + content: '确定要退出当前账号吗?', confirmColor: '#FF8C42', - success: (res) => { - if (res.confirm) { - Taro.removeStorageSync('token') - Taro.removeStorageSync('user_id') - Taro.removeStorageSync('userInfo') - Taro.reLaunch({ url: '/pages/login/index' }) - } - } + success: (result) => { + if (!result.confirm) return + clearToken() + clearSelectedBindingDeviceId() + Taro.removeStorageSync('userInfo') + Taro.reLaunch({ url: '/pages/login/index' }) + }, }) } const handleMenuClick = (item: MenuItem) => { if (item.disabled) return - + + if (item.name === '儿童资料 (用于称呼)') { + handleChildMenu() + return + } + if (item.name === '绑定新设备') { - if (binding) { - Taro.showToast({ title: '已有设备绑定', icon: 'none' }) - } else { - Taro.navigateTo({ url: '/pages/bind/index' }) - } - } else if (item.name === '解除设备绑定') { + Taro.navigateTo({ url: '/pages/bind/index' }) + return + } + + if (item.name === '切换设备') { + setShowDeviceModal(true) + return + } + + if (item.name === '解除设备绑定') { handleUnbind() - } else if (item.name.includes('儿童资料')) { - if (children.length > 0) { - handleOpenModal('edit', children[0]) - } else { - handleOpenModal('add') - } } } - if (!token) return null + if (loading) { + return ( + + + 加载中... + + + ) + } + const userId = getCurrentUserId() const menuItems: MenuItem[] = [ { icon: require('../../assets/tab-icons/orange-robot.png'), iconBgClass: 'orange', name: '儿童资料 (用于称呼)', - value: children.length > 0 ? children[0].child_name : '未设置', - arrow: true + value: currentChildName, + arrow: true, }, { icon: require('../../assets/tab-icons/rings.png'), iconBgClass: 'green', + name: '切换设备', + value: bindings.length > 0 ? `共 ${bindings.length} 台` : '暂无设备', + arrow: true, + }, + { + icon: require('../../assets/tab-icons/orange-robot.png'), + iconBgClass: 'orange', name: '绑定新设备', value: '', - arrow: true + arrow: true, }, { icon: require('../../assets/tab-icons/broken-rings.png'), iconBgClass: 'red', name: '解除设备绑定', - value: '', + value: binding?.device_id ? `当前: ${binding.device_id}` : '', arrow: true, - disabled: !binding - } + disabled: !binding, + }, ] - const firstChild = children[0] - const childDisplayValue = firstChild ? firstChild.child_name : '' - return ( @@ -214,24 +274,39 @@ export default function Sleep() { {parentInfo.avatar_url ? ( - + ) : ( 👤 )} {parentInfo.nickname || '家长用户'} - ID: {userId} + 用户 ID: {userId || '--'} + + + {bindings.length > 0 ? `已绑定 ${bindings.length} 台` : '未绑定设备'} + + + + + + 已绑定设备数 + {bindings.length} 台 + + + 当前设备 + {binding?.device_id || '未绑定'} + + + 当前儿童 + {currentChildName} {menuItems.map((item, index) => ( - handleMenuClick(item)} - > + handleMenuClick(item)}> @@ -239,9 +314,7 @@ export default function Sleep() { {item.name} - {item.name.includes('儿童资料') && ( - {childDisplayValue} - )} + {item.value && {item.value}} {item.arrow && } @@ -260,25 +333,66 @@ export default function Sleep() { 伴伴 Companion V1.1.0 - {/* Modal */} + {showDeviceModal && ( + setShowDeviceModal(false)}> + { + event.stopPropagation() + }} + > + 切换设备 + {bindings.length === 0 ? ( + 当前还没有已绑定设备 + ) : ( + + {bindings.map((item) => { + const isActive = binding?.device_id === item.device_id + return ( + handleSelectBinding(item)} + > + + {item.device_id} + {isActive && 当前} + + {item.child_name || '待关联儿童'} + + ) + })} + + )} + + setShowDeviceModal(false)}> + 关闭 + + + + + )} + {showModal && ( - - - {modalType === 'add' ? '添加儿童' : '修改昵称'} - - - + {modalType === 'add' ? '新建儿童资料' : '修改儿童昵称'} + + setChildName(e.detail.value)} - focus={true} + placeholder='请输入儿童昵称' + focus + onInput={(event) => setChildName(event.detail.value)} /> - - 取消 - 确认 + + + 取消 + + + 确认 + diff --git a/banban-mini/src/services/api.ts b/banban-mini/src/services/api.ts index ef3ad91..bda8f4d 100644 --- a/banban-mini/src/services/api.ts +++ b/banban-mini/src/services/api.ts @@ -1,7 +1,9 @@ import Taro from '@tarojs/taro' +import { API_BASE_URL } from '@/config/env' +import { getCurrentUserId, getToken, handleUnauthorized } from './session' -// const BASE_URL = 'http://175.24.73.253:8001' // 改为实际后端IP -const BASE_URL = 'http://127.0.0.1:8001' +const BASE_URL = API_BASE_URL +const REQUEST_TIMEOUT_MS = 10000 class ApiError extends Error { constructor(public status: number, message: string) { @@ -9,6 +11,16 @@ class ApiError extends Error { } } +function getErrorDetail(data: any): string { + if (!data) return 'Request failed' + if (typeof data.detail === 'string') return data.detail + if (Array.isArray(data.detail)) { + return data.detail.map((item: any) => String(item?.msg || item)).join('; ') + } + if (typeof data.message === 'string') return data.message + return 'Request failed' +} + async function request( url: string, options: { @@ -17,40 +29,42 @@ async function request( headers?: Record } = {} ): Promise { - const token = Taro.getStorageSync('token') - console.log(`[API] ${options.method || 'GET'} ${url}`, token ? 'with token' : 'no token') - + const token = getToken() const headers: Record = { 'Content-Type': 'application/json', ...options.headers, } if (token) { - headers['Authorization'] = `Bearer ${token}` + headers.Authorization = `Bearer ${token}` } - const response = await Taro.request({ - url: `${BASE_URL}${url}`, - method: options.method || 'GET', - data: options.data, - header: headers, - }) + let response + try { + response = await Taro.request({ + url: `${BASE_URL}${url}`, + method: options.method || 'GET', + data: options.data, + header: headers, + timeout: REQUEST_TIMEOUT_MS, + }) + } catch (error: any) { + const message = String(error?.errMsg || error?.message || '') + const detail = message.includes('timeout') + ? '请求超时,请检查后端服务是否可访问' + : '网络请求失败,请检查后端地址和网络连接' + throw new ApiError(0, detail) + } + + if (response.statusCode === 401) { + handleUnauthorized({ redirect: !url.startsWith('/auth/') }) + } - console.log(`[API] Response status:`, response.statusCode) - if (response.statusCode >= 400) { - const detail = response.data?.detail || 'Request failed' - throw new ApiError(response.statusCode, detail) + throw new ApiError(response.statusCode, getErrorDetail(response.data)) } - return response.data + return response.data as T } export { request, ApiError, BASE_URL } - -export function getToken(): string { - return Taro.getStorageSync('token') || '' -} - -export function getCurrentUserId(): number { - return Taro.getStorageSync('user_id') || 0 -} \ No newline at end of file +export { getCurrentUserId, getToken } diff --git a/banban-mini/src/services/auth.ts b/banban-mini/src/services/auth.ts index cf2511c..9b2ef9a 100644 --- a/banban-mini/src/services/auth.ts +++ b/banban-mini/src/services/auth.ts @@ -1,4 +1,23 @@ -import { request, BASE_URL } from './api' +import { + clearLoginSession, + getCurrentUserId as getStoredCurrentUserId, + getToken as getStoredToken, + saveLoginSession, +} from './session' +import { request } from './api' + +export interface WechatLoginPayload { + code: string + nickname?: string + avatar_url?: string +} + +export interface LoginSession { + access_token: string + token_type: string + expires_in: number + user_id: number +} export interface Parent { user_id: number @@ -10,44 +29,19 @@ export interface Parent { status: number } -// 微信登录 -export async function wechatLogin(code: string): Promise<{ token: string; user_id: number }> { - // TODO: 实现微信登录 - 通过 code 换取 openid,然后调用后端 - // 这里先返回 mock 数据 - const mockParent: Parent = { - user_id: 1, - openid: 'mock_openid_' + code, - nickname: '微信用户', - status: 1, - } - - // 保存模拟 token - const token = 'mock_token_' + Date.now() - Taro.setStorageSync('token', token) - Taro.setStorageSync('user_id', mockParent.user_id) - - return { token, user_id: mockParent.user_id } -} - -// 创建家长 -export async function createParent(data: { - openid: string - unionid?: string - nickname?: string - avatar_url?: string -}): Promise { - return request('/parents', { +export async function wechatLogin(data: WechatLoginPayload): Promise { + const session = await request('/auth/login', { method: 'POST', data, }) + saveLoginSession(session) + return session } -// 获取家长信息 export async function getParent(userId: number): Promise { return request(`/parents/${userId}`) } -// 更新家长信息 export async function updateParent( userId: number, data: { nickname?: string; avatar_url?: string; phone?: string } @@ -58,18 +52,14 @@ export async function updateParent( }) } -// 获取 token export function getToken(): string { - return Taro.getStorageSync('token') + return getStoredToken() } -// 清除 token export function clearToken(): void { - Taro.removeStorageSync('token') - Taro.removeStorageSync('user_id') + clearLoginSession() } -// 获取当前用户ID export function getCurrentUserId(): number { - return Taro.getStorageSync('user_id') || 0 -} \ No newline at end of file + return getStoredCurrentUserId() +} diff --git a/banban-mini/src/services/binding.ts b/banban-mini/src/services/binding.ts index 79270dc..e0501a5 100644 --- a/banban-mini/src/services/binding.ts +++ b/banban-mini/src/services/binding.ts @@ -1,97 +1,126 @@ +import Taro from '@tarojs/taro' import { request } from './api' +const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId' + +function buildQuery(params: Record): string { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .join('&') +} + export interface Binding { device_id: string child_id: number | null + child_name?: string | null status: number bound_at: string } -export interface BindStartResponse { - bind_token: string - expires_at: string -} +export interface BindingListItem extends Binding {} -export interface BindHistoryItem { - device_id: string - child_id: number | null - bound_at: string - unbound_at?: string -} - -export interface BindHistoryResponse { - items: BindHistoryItem[] +export interface BindingListResponse { + items: BindingListItem[] total: number - next_cursor?: string + next_cursor?: number | null +} + +export interface DirectBindPayload { + device_id: string + serial_number: string + child_id?: number +} + +export function getSelectedBindingDeviceId(): string | null { + const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) + const deviceId = String(value || '').trim() + return deviceId || null +} + +export function setSelectedBindingDeviceId(deviceId?: string | null) { + const normalizedDeviceId = String(deviceId || '').trim() + if (!normalizedDeviceId) { + Taro.removeStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) + return + } + Taro.setStorageSync(SELECTED_BINDING_DEVICE_ID_KEY, normalizedDeviceId) +} + +export function clearSelectedBindingDeviceId() { + Taro.removeStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) +} + +export function resolveBindingSelection(items: T[]): T | null { + const selectedDeviceId = getSelectedBindingDeviceId() + const selectedBinding = selectedDeviceId ? items.find((item) => item.device_id === selectedDeviceId) || null : null + const activeBinding = selectedBinding || items[0] || null + + if (activeBinding) { + setSelectedBindingDeviceId(activeBinding.device_id) + } else { + clearSelectedBindingDeviceId() + } + + return activeBinding } -// 获取当前绑定信息 export async function getCurrentBinding(): Promise { try { return await request('/bindings/current') - } catch (err: any) { - if (err.status === 404) { - return null - } - throw err + } catch (error: any) { + if (error?.status === 404) return null + throw error } } -// 开始绑定设备 -export async function startBind(data: { - device_id: string - child_id?: number -}): Promise { - return request('/bindings/start', { - method: 'POST', - data, +export async function getBindings(cursor?: number, limit: number = 20): Promise { + const query = buildQuery({ + cursor, + limit, }) + return request(`/bindings?${query}`) } -// 确认绑定设备 -export async function confirmBind(data: { - bind_token: string - challenge_code: string -}): Promise<{ device_id: string; child_id: number | null }> { - return request('/bindings/confirm', { - method: 'POST', - data, - }) -} - -// 获取设备绑定信息 export async function getBinding(deviceId: string): Promise { - // If no deviceId, get the first binding for this user - if (!deviceId) { - try { - // Try to get binding list - for now return null if no specific device - return null - } catch { - return null - } + try { + return await request(`/bindings/${deviceId}`) + } catch (error: any) { + if (error?.status === 404) return null + throw error } - return request(`/bindings/${deviceId}`) } -// 解绑设备 -export async function unbindDevice(deviceId: string): Promise { - return request(`/bindings/${deviceId}`, { - method: 'DELETE', - }) +export async function resolveActiveBinding(): Promise { + const selectedDeviceId = getSelectedBindingDeviceId() + + if (selectedDeviceId) { + const selectedBinding = await getBinding(selectedDeviceId) + if (selectedBinding) { + setSelectedBindingDeviceId(selectedBinding.device_id) + return selectedBinding + } + + clearSelectedBindingDeviceId() + } + + const currentBinding = await getCurrentBinding() + if (currentBinding?.device_id) { + setSelectedBindingDeviceId(currentBinding.device_id) + } else { + clearSelectedBindingDeviceId() + } + + return currentBinding } -// 直接绑定设备(手动输入设备码) -export async function directBind(data: { - device_id: string - child_id?: number -}): Promise<{ device_id: string; child_id: number | null }> { +export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> { return request('/bindings/direct', { method: 'POST', data, }) } -// Bind child profile after device has been bound to parent account. export async function setBindingChild( deviceId: string, data: { child_id: number } @@ -102,15 +131,8 @@ export async function setBindingChild( }) } -// 获取绑定历史 -export async function getBindHistory( - deviceId: string, - cursor?: string, - limit: number = 20 -): Promise { - const params = new URLSearchParams() - if (cursor) params.append('cursor', cursor) - params.append('limit', String(limit)) - - return request(`/bindings/history/${deviceId}?${params.toString()}`) +export async function unbindDevice(deviceId: string): Promise { + return request(`/bindings/${deviceId}`, { + method: 'DELETE', + }) } diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index cc7b1e2..b5b8505 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -1,21 +1,50 @@ -// 聊天相关接口定义 +import { getCurrentUserId } from './auth' +import { request } from './api' +import { resolveActiveBinding } from './binding' +import { getChild } from './child' + +export type ConversationSource = 'im' | 'ai' +export type PeerKind = 'child' | 'parent' | 'ai' + +const PARENT_CHILD_CONVERSATION_TYPE_NAME = 'parent_child' export interface ChatConversation { id: number + key: string + source: ConversationSource + peerKind: PeerKind name: string avatar: string + typeLabel: string + description: string lastMessage: string time: string - unreadCount?: number + sortAt: string + roleKey?: string + conversationTypeName?: string + peerId?: string + childId?: number | null + childName?: string + parentUserId?: number | null + deviceId?: string + channelLabel: string + canSend: boolean + isSynthetic?: boolean } export interface ChatMessage { id: number - type: 'user' | 'ai' + type: 'user' | 'peer' content: string time: string - isAudio?: boolean - audioDuration?: string + conversationId: number + senderType?: string + senderId?: string + receiverType?: string + receiverId?: string + senderName?: string + receiverName?: string + channelLabel?: string } export interface Character { @@ -23,196 +52,540 @@ export interface Character { name: string description: string icon: string + peerKind: PeerKind + source: ConversationSource + conversationTypeName?: string + childId?: number | null + childName?: string + peerId?: string + parentUserId?: number | null + deviceId?: string + channelLabel: string + canSend: boolean } -// Mock 数据 - 会话列表 -const mockConversations: ChatConversation[] = [ - { - id: 1, - name: '阳光知心伴伴', - avatar: '🎤', - lastMessage: '因为太阳光里面其实藏着七种颜色呀...', - time: '14:31', - unreadCount: 2 - }, - { - id: 2, - name: '智慧小博士', - avatar: '📚', - lastMessage: '好的,我来给你讲一个关于恐龙的故事...', - time: '昨天' - }, - { - id: 3, - name: '音乐小精灵', - avatar: '🎵', - lastMessage: '《拔萝卜》已经唱完啦,还要听别的吗?', - time: '昨天' - }, - { - id: 4, - name: '故事大王', - avatar: '📖', - lastMessage: '从前有一只勇敢的小兔子...', - time: '周一' - }, - { - id: 5, - name: '英语小老师', - avatar: '🔤', - lastMessage: 'Apple, A-P-P-L-E, apple!', - time: '周一' +interface DeviceAiMessageItem { + id: number + conversation_id: number + role_key: string + is_user: boolean + speaker: 'user' | 'assistant' + content: string + timestamp: number + created_at: string +} + +interface DeviceAiMessageListResponse { + items: DeviceAiMessageItem[] + total: number + next_cursor?: number | null +} + +interface ChildConversationItem { + conversation_id: number + conversation_type: number + conversation_type_name: string + peer_type: 'child' | 'parent' + peer_id: string + peer_name?: string | null + last_message_preview?: string | null + last_message_at?: string | null + message_count: number +} + +interface ChildConversationListResponse { + items: ChildConversationItem[] + total: number + next_cursor?: number | null +} + +interface ChildConversationMessageItem { + id: number + conversation_id: number + seq: number + sender_type: 'child' | 'parent' + sender_id: string + receiver_type: 'child' | 'parent' + receiver_id: string + content_type: number + content_text?: string | null + content_json?: Record | null + media_file_key?: string | null + media_duration_ms?: number | null + media_mime_type?: string | null + media_size_bytes?: number | null + media_transcript_text?: string | null + client_msg_id?: string | null + sender_name_snapshot?: string | null + receiver_name_snapshot?: string | null + ext_json?: Record | null + created_at: string +} + +interface ChildConversationMessageListResponse { + conversation_id: number + has_more: boolean + next_cursor_seq?: number | null + items: ChildConversationMessageItem[] +} + +interface ConversationMessageCreateResponse { + idempotent: boolean + conversation_id: number + conversation_type: number + conversation_type_name: string + message: ChildConversationMessageItem +} + +interface BindingContext { + deviceId: string + childId: number | null + childName: string + currentUserId: number +} + +const PEER_META: Record = { + child: { icon: '🧒', label: '儿童', description: '孩子正在交流的儿童玩伴' }, + parent: { icon: '👨', label: '家长', description: '家长通过微信小程序和孩子沟通' }, + ai: { icon: '🤖', label: 'AI', description: '孩子与 AI 的历史交流' }, +} + +const AI_ROLE_META: Record = { + assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' }, +} + +export function isParentChildConversation(conversationTypeName?: string | null): boolean { + return conversationTypeName === PARENT_CHILD_CONVERSATION_TYPE_NAME +} + +function parseTime(value?: string | null): number { + if (!value) return 0 + const timestamp = new Date(value).getTime() + return Number.isNaN(timestamp) ? 0 : timestamp +} + +function parseNumericId(value?: string | number | null): number | null { + const normalizedValue = Number(value) + if (!Number.isFinite(normalizedValue) || normalizedValue <= 0) return null + return normalizedValue +} + +function formatListTime(value?: string | null): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const now = new Date() + const isSameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate() + if (isSameDay) { + return `${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}` } -] + return `${date.getMonth() + 1}/${date.getDate()}` +} -// Mock 数据 - 消息列表 -const mockMessages: Record = { - 1: [ - { - id: 1, - type: 'user', - content: '天空为什么是蓝色的呀?', - time: '今天 14:30' - }, - { - id: 2, - type: 'ai', - content: '因为太阳光里面其实藏着七种颜色呀。当光穿过大气层的时候,蓝色的光最调皮,到处乱跑,所以我们看到的天空就是蓝色的啦!', - time: '今天 14:31' - }, - { - id: 3, - type: 'user', - content: '给我唱一首拔萝卜吧!', - time: '今天 15:15' - }, - { - id: 4, - type: 'ai', - content: '', - time: '今天 15:15', - isAudio: true, - audioDuration: '01:45' +function formatDetailTime(value?: string | null): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hour = `${date.getHours()}`.padStart(2, '0') + const minute = `${date.getMinutes()}`.padStart(2, '0') + return `${month}-${day} ${hour}:${minute}` +} + +function formatImPreview( + item: Pick +): string { + if (item.content_type === 1) return item.content_text || '文本消息' + if (item.content_type === 2) return item.media_transcript_text || '[语音]' + if (item.content_type === 3) return '[图片]' + if (item.content_type === 4) { + const title = String(item.content_json?.title || item.content_json?.type || '').trim() + return title ? `[卡片] ${title}` : '[卡片]' + } + return '[消息]' +} + +function createClientMessageId(): string { + return `parent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function getAiMeta(roleKey?: string): { name: string; icon: string; description: string } { + if (roleKey && AI_ROLE_META[roleKey]) return AI_ROLE_META[roleKey] + return { + name: roleKey || 'AI', + icon: '🤖', + description: `孩子与 ${roleKey || 'AI'} 的历史交流`, + } +} + +function getParticipantDisplayName(type: string, id?: string, name?: string | null): string { + if (name && name.trim()) return name.trim() + if (type === 'child') return id ? `儿童 ${id}` : '儿童' + if (type === 'parent') return id ? `家长 ${id}` : '家长' + return id || 'AI' +} + +function getPeerDisplayName(peerKind: PeerKind, peerId?: string, peerName?: string | null, roleKey?: string): string { + if (peerKind === 'ai') return getAiMeta(roleKey).name + return getParticipantDisplayName(peerKind, peerId, peerName) +} + +function getConversationRank(conversation: ChatConversation): number { + if (isParentChildConversation(conversation.conversationTypeName)) return 0 + return 1 +} + +async function getBindingContext(): Promise { + const binding = await resolveActiveBinding() + let childName = String(binding?.child_name || '').trim() + + if (!childName && binding?.child_id) { + try { + const child = await getChild(binding.child_id) + childName = String(child?.child_name || '').trim() + } catch (error) { + console.error('[chat] load child name failed:', error) } - ], - 2: [ - { - id: 1, - type: 'user', - content: '你知道恐龙吗?', - time: '昨天 10:30' - }, - { - id: 2, - type: 'ai', - content: '当然知道!恐龙是生活在很久很久以前的大型爬行动物,有的恐龙比房子还要大呢!', - time: '昨天 10:31' - }, - { - id: 3, - type: 'user', - content: '那给我讲一个恐龙的故事吧', - time: '昨天 10:32' - }, - { - id: 4, - type: 'ai', - content: '好的,我来给你讲一个关于恐龙的故事...', - time: '昨天 10:33' - } - ], - 3: [ - { - id: 1, - type: 'user', - content: '我想听拔萝卜', - time: '昨天 16:00' - }, - { - id: 2, - type: 'ai', - content: '', - time: '昨天 16:01', - isAudio: true, - audioDuration: '02:30' - }, - { - id: 3, - type: 'ai', - content: '《拔萝卜》已经唱完啦,还要听别的吗?', - time: '昨天 16:04' - } - ], - 4: [ - { - id: 1, - type: 'user', - content: '给我讲个故事吧', - time: '周一 20:00' - }, - { - id: 2, - type: 'ai', - content: '从前有一只勇敢的小兔子...', - time: '周一 20:01' - } - ], - 5: [ - { - id: 1, - type: 'user', - content: '苹果用英语怎么说?', - time: '周一 18:00' - }, - { - id: 2, - type: 'ai', - content: 'Apple, A-P-P-L-E, apple!', - time: '周一 18:01' + } + + return { + deviceId: binding?.device_id || '', + childId: binding?.child_id || null, + childName, + currentUserId: getCurrentUserId(), + } +} + +async function getDeviceMessages(context?: BindingContext): Promise { + const { deviceId } = context || (await getBindingContext()) + if (!deviceId) return [] + + try { + const response = await request(`/devices/${deviceId}/messages?limit=100`) + return response.items || [] + } catch (error: any) { + if (error?.status === 404) return [] + throw error + } +} + +async function getChildConversations(context?: BindingContext): Promise { + const { childId } = context || (await getBindingContext()) + if (!childId) return [] + + try { + const response = await request(`/children/${childId}/conversations?limit=100`) + return response.items || [] + } catch (error: any) { + if (error?.status === 404) return [] + throw error + } +} + +async function getChildConversationMessages( + conversationId: number, + context?: BindingContext +): Promise { + const { childId } = context || (await getBindingContext()) + if (!childId || conversationId <= 0) return [] + + const response = await request( + `/children/${childId}/conversations/${conversationId}/messages?limit=100` + ) + return response.items || [] +} + +function toImConversation(item: ChildConversationItem, context: BindingContext): ChatConversation { + const isParentConversation = isParentChildConversation(item.conversation_type_name) + const peerKind: PeerKind = item.peer_type === 'parent' ? 'parent' : 'child' + const meta = PEER_META[peerKind] + + return { + id: item.conversation_id, + key: `im-${item.conversation_id}`, + source: 'im', + peerKind, + name: isParentConversation ? '家长沟通' : getPeerDisplayName(peerKind, item.peer_id, item.peer_name), + avatar: meta.icon, + typeLabel: isParentConversation ? '家长沟通' : meta.label, + description: isParentConversation ? '家长通过微信小程序和孩子直接沟通' : meta.description, + lastMessage: item.last_message_preview || (isParentConversation ? '还没有消息,点进去发送第一条' : '暂无消息'), + time: formatListTime(item.last_message_at), + sortAt: item.last_message_at || '', + conversationTypeName: item.conversation_type_name, + peerId: item.peer_id, + childId: context.childId, + childName: context.childName, + parentUserId: isParentConversation ? parseNumericId(item.peer_id) : null, + deviceId: context.deviceId, + channelLabel: isParentConversation ? '微信小程序' : '设备端', + canSend: isParentConversation, + } +} + +function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): ChatConversation { + const meta = getAiMeta(item.role_key) + + return { + id: item.conversation_id, + key: `ai-${item.conversation_id}`, + source: 'ai', + peerKind: 'ai', + name: meta.name, + avatar: meta.icon, + typeLabel: PEER_META.ai.label, + description: meta.description, + lastMessage: item.content || '暂无消息', + time: formatListTime(item.created_at), + sortAt: item.created_at, + roleKey: item.role_key, + conversationTypeName: 'ai', + peerId: item.role_key, + childId: context.childId, + childName: context.childName, + parentUserId: null, + deviceId: context.deviceId, + channelLabel: 'AI', + canSend: false, + } +} + +function createSyntheticParentConversation(context: BindingContext): ChatConversation | null { + if (!context.childId || !context.currentUserId) return null + + return { + id: 0, + key: `im-parent-child-${context.childId}-${context.currentUserId}`, + source: 'im', + peerKind: 'parent', + name: '家长沟通', + avatar: PEER_META.parent.icon, + typeLabel: '家长沟通', + description: '家长通过微信小程序和孩子直接沟通', + lastMessage: '还没有消息,点进去发送第一条', + time: '', + sortAt: '', + conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME, + peerId: String(context.currentUserId), + childId: context.childId, + childName: context.childName, + parentUserId: context.currentUserId, + deviceId: context.deviceId, + channelLabel: '微信小程序', + canSend: true, + isSynthetic: true, + } +} + +export async function getConversations(): Promise { + const context = await getBindingContext() + const [imConversationItems, aiMessages] = await Promise.all([getChildConversations(context), getDeviceMessages(context)]) + const imConversations = imConversationItems.map((item) => toImConversation(item, context)) + const aiConversationMap = new Map() + + for (const item of aiMessages) { + if (!aiConversationMap.has(item.conversation_id)) { + aiConversationMap.set(item.conversation_id, item) } + } + + const conversations = [ + ...imConversations, + ...Array.from(aiConversationMap.values()).map((item) => toAiConversation(item, context)), ] -} -// Mock 数据 - 角色信息 -const mockCharacters: Record = { - 1: { id: 1, name: '阳光知心伴伴', description: '温柔音色 / 开朗耐心', icon: '🎤' }, - 2: { id: 2, name: '智慧小博士', description: '博学多才 / 爱讲故事', icon: '📚' }, - 3: { id: 3, name: '音乐小精灵', description: '歌声甜美 / 会唱儿歌', icon: '🎵' }, - 4: { id: 4, name: '故事大王', description: '故事丰富 / 生动有趣', icon: '📖' }, - 5: { id: 5, name: '英语小老师', description: '英语启蒙 / 发音标准', icon: '🔤' } -} + const hasParentConversation = conversations.some( + (item) => + isParentChildConversation(item.conversationTypeName) && + item.parentUserId === context.currentUserId + ) -/** - * 获取会话列表 - */ -export function getConversations(): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve([...mockConversations]) - }, 300) + if (!hasParentConversation) { + const syntheticConversation = createSyntheticParentConversation(context) + if (syntheticConversation) { + conversations.push(syntheticConversation) + } + } + + return conversations.sort((left, right) => { + const rankDiff = getConversationRank(left) - getConversationRank(right) + if (rankDiff !== 0) return rankDiff + return parseTime(right.sortAt) - parseTime(left.sortAt) }) } -/** - * 获取会话消息列表 - * @param conversationId 会话ID - */ -export function getMessages(conversationId: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(mockMessages[conversationId] || []) - }, 300) - }) +export async function getMessages( + conversationId: number, + source: ConversationSource, + conversationTypeName?: string +): Promise { + const context = await getBindingContext() + + if (source === 'ai') { + const items = await getDeviceMessages(context) + return items + .filter((item) => item.conversation_id === conversationId) + .slice() + .reverse() + .map((item) => ({ + id: item.id, + type: item.is_user ? 'user' : 'peer', + content: item.content || '暂无内容', + time: formatDetailTime(item.created_at), + conversationId: item.conversation_id, + senderType: item.is_user ? 'device' : 'ai', + senderId: item.is_user ? context.deviceId : item.role_key, + receiverType: item.is_user ? 'ai' : 'device', + receiverId: item.is_user ? item.role_key : context.deviceId, + senderName: item.is_user ? context.childName || '孩子设备' : getAiMeta(item.role_key).name, + receiverName: item.is_user ? getAiMeta(item.role_key).name : context.childName || '孩子设备', + channelLabel: 'AI', + })) + } + + const items = await getChildConversationMessages(conversationId, context) + const isParentConversation = isParentChildConversation(conversationTypeName) + + return items.map((item) => ({ + id: item.id, + type: isParentConversation + ? item.sender_type === 'parent' && item.sender_id === String(context.currentUserId) + ? 'user' + : 'peer' + : item.sender_type === 'child' && item.sender_id === String(context.childId || '') + ? 'user' + : 'peer', + content: formatImPreview(item), + time: formatDetailTime(item.created_at), + conversationId: item.conversation_id, + senderType: item.sender_type, + senderId: item.sender_id, + receiverType: item.receiver_type, + receiverId: item.receiver_id, + senderName: + item.sender_type === 'parent' && item.sender_id === String(context.currentUserId) + ? '我' + : item.sender_type === 'child' && item.sender_id === String(context.childId || '') + ? context.childName || item.sender_name_snapshot || '孩子' + : getParticipantDisplayName(item.sender_type, item.sender_id, item.sender_name_snapshot), + receiverName: + item.receiver_type === 'parent' && item.receiver_id === String(context.currentUserId) + ? '我' + : item.receiver_type === 'child' && item.receiver_id === String(context.childId || '') + ? context.childName || item.receiver_name_snapshot || '孩子' + : getParticipantDisplayName(item.receiver_type, item.receiver_id, item.receiver_name_snapshot), + channelLabel: + isParentConversation && (item.sender_type === 'parent' || item.receiver_type === 'parent') + ? '微信小程序' + : '设备端', + })) } -/** - * 获取角色信息 - * @param characterId 角色ID - */ -export function getCharacter(characterId: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(mockCharacters[characterId] || null) - }, 300) +export async function getCharacter(options: { + conversationId: number + source: ConversationSource + peerKind?: PeerKind + name?: string + roleKey?: string + conversationTypeName?: string + childId?: number | null + childName?: string + peerId?: string + parentUserId?: number | null + deviceId?: string + channelLabel?: string + canSend?: boolean +}): Promise { + const { + conversationId, + source, + peerKind = 'ai', + name, + roleKey, + conversationTypeName, + childId, + childName, + peerId, + parentUserId, + deviceId, + channelLabel, + canSend = false, + } = options + + if (source === 'ai') { + const meta = getAiMeta(roleKey) + return { + id: conversationId, + name: name || meta.name, + description: 'AI 对话通道', + icon: meta.icon, + peerKind: 'ai', + source, + conversationTypeName: 'ai', + childId, + childName, + peerId: peerId || roleKey, + parentUserId: null, + deviceId, + channelLabel: channelLabel || 'AI', + canSend: false, + } + } + + if (isParentChildConversation(conversationTypeName)) { + return { + id: conversationId, + name: '家长沟通', + description: '微信小程序 <-> 儿童设备', + icon: PEER_META.parent.icon, + peerKind: 'parent', + source, + conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME, + childId, + childName, + peerId, + parentUserId: parentUserId || parseNumericId(peerId), + deviceId, + channelLabel: channelLabel || '微信小程序', + canSend: canSend || true, + } + } + + const meta = PEER_META[peerKind] + return { + id: conversationId, + name: getPeerDisplayName(peerKind, peerId, name), + description: peerKind === 'child' ? '儿童设备间的聊天记录' : meta.description, + icon: meta.icon, + peerKind, + source, + conversationTypeName, + childId, + childName, + peerId, + parentUserId, + deviceId, + channelLabel: channelLabel || '设备端', + canSend, + } +} + +export async function sendParentMessage(childId: number, content: string): Promise { + const normalizedContent = content.trim() + if (!normalizedContent) { + throw new Error('请输入消息内容') + } + + return request(`/children/${childId}/messages`, { + method: 'POST', + data: { + content_type: 1, + content_text: normalizedContent, + client_msg_id: createClientMessageId(), + }, }) } diff --git a/banban-mini/src/services/child.ts b/banban-mini/src/services/child.ts index 034ef59..67b8f29 100644 --- a/banban-mini/src/services/child.ts +++ b/banban-mini/src/services/child.ts @@ -1,5 +1,12 @@ import { request } from './api' +function buildQuery(params: Record): string { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .join('&') +} + export interface Child { child_id: number child_name: string @@ -11,10 +18,9 @@ export interface Child { export interface ChildListResponse { items: Child[] total: number - next_cursor?: number + next_cursor?: number | null } -// 创建小孩档案 export async function createChild(data: { child_name: string child_gender?: number @@ -26,24 +32,18 @@ export async function createChild(data: { }) } -// 获取小孩列表 -export async function getChildren( - cursor?: number, - limit: number = 20 -): Promise { - const params = new URLSearchParams() - if (cursor) params.append('cursor', String(cursor)) - params.append('limit', String(limit)) - - return request(`/children?${params.toString()}`) +export async function getChildren(cursor?: number, limit: number = 20): Promise { + const query = buildQuery({ + cursor, + limit, + }) + return request(`/children?${query}`) } -// 获取单个小孩信息 export async function getChild(childId: number): Promise { return request(`/children/${childId}`) } -// 更新小孩信息 export async function updateChild( childId: number, data: { child_name?: string; child_gender?: number; child_birthday?: string } @@ -52,4 +52,4 @@ export async function updateChild( method: 'PATCH', data, }) -} \ No newline at end of file +} diff --git a/banban-mini/src/services/location.ts b/banban-mini/src/services/location.ts new file mode 100644 index 0000000..7375451 --- /dev/null +++ b/banban-mini/src/services/location.ts @@ -0,0 +1,67 @@ +import { request } from './api' +import { resolveActiveBinding } from './binding' + +export interface DeviceLocation { + child_id: number + child_name?: string | null + device_id: string + coord_type: string + lat: number + lng: number + accuracy_m?: number | null + altitude_m?: number | null + speed_mps?: number | null + heading_deg?: number | null + source: number + battery_pct?: number | null + device_time: string + server_time: string + updated_at: string +} + +export interface DeviceTrajectoryPoint extends DeviceLocation { + id: number + created_at: string +} + +export interface DeviceTrajectoryResponse { + items: DeviceTrajectoryPoint[] + total: number + start_at?: string | null + end_at?: string | null +} + +export async function getCurrentDeviceLocation(deviceId?: string): Promise { + const resolvedDeviceId = String(deviceId || '').trim() || (await resolveActiveBinding())?.device_id || '' + if (!resolvedDeviceId) return null + + try { + return await request(`/devices/${resolvedDeviceId}/location`) + } catch (error: any) { + if (error?.status === 404) return null + throw error + } +} + +export async function getDeviceTrajectory(params?: { + deviceId?: string + startAt?: string + endAt?: string + limit?: number +}): Promise { + const resolvedDeviceId = String(params?.deviceId || '').trim() || (await resolveActiveBinding())?.device_id || '' + if (!resolvedDeviceId) return null + + const query = new URLSearchParams() + if (params?.startAt) query.append('start_at', params.startAt) + if (params?.endAt) query.append('end_at', params.endAt) + if (params?.limit) query.append('limit', String(params.limit)) + + try { + const suffix = query.toString() ? `?${query.toString()}` : '' + return await request(`/devices/${resolvedDeviceId}/trajectory${suffix}`) + } catch (error: any) { + if (error?.status === 404) return null + throw error + } +} diff --git a/banban-mini/src/services/session.ts b/banban-mini/src/services/session.ts new file mode 100644 index 0000000..4f8e53b --- /dev/null +++ b/banban-mini/src/services/session.ts @@ -0,0 +1,72 @@ +import Taro from '@tarojs/taro' + +const TOKEN_KEY = 'token' +const TOKEN_TYPE_KEY = 'token_type' +const USER_ID_KEY = 'user_id' +const EXPIRES_IN_KEY = 'expires_in' +const TOKEN_EXPIRES_AT_KEY = 'token_expires_at' +const LOGIN_PAGE_URL = '/pages/login/index' + +let redirectingToLogin = false + +export interface StoredLoginSession { + access_token: string + token_type: string + expires_in: number + user_id: number +} + +export function saveLoginSession(session: StoredLoginSession): void { + const expiresAt = Date.now() + Math.max(session.expires_in, 0) * 1000 + Taro.setStorageSync(TOKEN_KEY, session.access_token) + Taro.setStorageSync(TOKEN_TYPE_KEY, session.token_type) + Taro.setStorageSync(USER_ID_KEY, session.user_id) + Taro.setStorageSync(EXPIRES_IN_KEY, session.expires_in) + Taro.setStorageSync(TOKEN_EXPIRES_AT_KEY, expiresAt) +} + +export function clearLoginSession(): void { + Taro.removeStorageSync(TOKEN_KEY) + Taro.removeStorageSync(TOKEN_TYPE_KEY) + Taro.removeStorageSync(USER_ID_KEY) + Taro.removeStorageSync(EXPIRES_IN_KEY) + Taro.removeStorageSync(TOKEN_EXPIRES_AT_KEY) +} + +function getTokenExpiresAt(): number { + const rawValue = Number(Taro.getStorageSync(TOKEN_EXPIRES_AT_KEY) || 0) + return Number.isFinite(rawValue) ? rawValue : 0 +} + +export function getToken(): string { + const token = Taro.getStorageSync(TOKEN_KEY) || '' + if (!token) return '' + + const expiresAt = getTokenExpiresAt() + if (expiresAt > 0 && Date.now() >= expiresAt) { + clearLoginSession() + return '' + } + + return token +} + +export function getCurrentUserId(): number { + if (!getToken()) return 0 + return Number(Taro.getStorageSync(USER_ID_KEY) || 0) +} + +export function handleUnauthorized(options: { redirect?: boolean } = {}): void { + const { redirect = true } = options + clearLoginSession() + + if (!redirect || redirectingToLogin) return + + redirectingToLogin = true + setTimeout(() => { + Taro.reLaunch({ url: LOGIN_PAGE_URL }) + setTimeout(() => { + redirectingToLogin = false + }, 600) + }, 120) +} diff --git a/database/migrate_to_shared_talkingq.py b/database/migrate_to_shared_talkingq.py new file mode 100644 index 0000000..eda5a18 --- /dev/null +++ b/database/migrate_to_shared_talkingq.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import pymysql + + +ROOT_DIR = Path(__file__).resolve().parent +DEFAULT_SCHEMA_PATH = ROOT_DIR / "talkingq_shared_schema.sql" +DEFAULT_ENV_PATH = ROOT_DIR.parent / "mini-program" / ".env" +DEFAULT_TARGET_DB = "talkingq" +DEFAULT_LEGACY_DB = "mini_program" + +TALKINGQ_URL_TABLES = [ + "device_auth", + "device_configs", + "conversation_histories", + "conversation_messages", + "roles", + "role_languages", + "device_firmware_update", + "system_config", +] + +MINI_PROGRAM_TABLES = [ + "parents", + "children", + "parent_child_relations", + "device_bindings", + "device_bind_sessions", + "device_bind_history", + "cards", + "device_settings", + "im_conversations", + "im_messages", + "child_location_current", + "child_location_history", +] + + +def _env_first(*keys: str, default: str) -> str: + for key in keys: + value = os.getenv(key) + if value: + return value + return default + + +def load_env_file(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + + env: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + env[key] = value + return env + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Apply the shared Banban schema to the single source-of-truth database. " + "Existing talkingq-url data in the target database is preserved; legacy " + "mini_program data is ignored." + ) + ) + parser.add_argument( + "--env-file", + default=str(DEFAULT_ENV_PATH), + help="Optional env file used as fallback for DB_HOST/DB_PORT/DB_USER/DB_PASSWORD.", + ) + parser.add_argument("--host") + parser.add_argument("--port", type=int) + parser.add_argument("--user") + parser.add_argument("--password") + parser.add_argument("--target-db") + parser.add_argument("--legacy-db") + parser.add_argument("--schema", default=str(DEFAULT_SCHEMA_PATH)) + parser.add_argument( + "--drop-legacy-db", + action="store_true", + help="Drop the legacy mini_program database after the shared schema has been applied.", + ) + args = parser.parse_args() + + env_values = load_env_file(Path(args.env_file).expanduser().resolve()) + args.host = args.host or os.getenv("MYSQL_HOST") or os.getenv("DB_HOST") or env_values.get("DB_HOST") or "127.0.0.1" + args.port = ( + args.port + or int(os.getenv("MYSQL_PORT") or os.getenv("DB_PORT") or env_values.get("DB_PORT") or "3306") + ) + args.user = args.user or os.getenv("MYSQL_USER") or os.getenv("DB_USER") or env_values.get("DB_USER") or "root" + args.password = ( + args.password + or os.getenv("MYSQL_PASSWORD") + or os.getenv("DB_PASSWORD") + or env_values.get("DB_PASSWORD") + or "" + ) + args.target_db = args.target_db or DEFAULT_TARGET_DB + args.legacy_db = args.legacy_db or DEFAULT_LEGACY_DB + return args + + +def split_sql_statements(sql: str) -> list[str]: + statements: list[str] = [] + buffer: list[str] = [] + quote_char: str | None = None + escaped = False + + for char in sql: + buffer.append(char) + if quote_char is not None: + if escaped: + escaped = False + elif char == "\\" and quote_char in {"'", '"'}: + escaped = True + elif char == quote_char: + quote_char = None + continue + + if char in {"'", '"', "`"}: + quote_char = char + continue + + if char == ";": + statement = "".join(buffer).strip() + if statement: + statements.append(statement) + buffer = [] + + tail = "".join(buffer).strip() + if tail: + statements.append(tail) + return statements + + +def load_schema_sql(path: Path, *, target_db: str) -> str: + raw_sql = path.read_text(encoding="utf-8-sig") + sql_lines = [] + for line in raw_sql.splitlines(): + stripped = line.lstrip() + if stripped.startswith("--"): + continue + sql_lines.append(line) + sql = "\n".join(sql_lines) + sql = sql.replace("CREATE DATABASE IF NOT EXISTS `talkingq`", f"CREATE DATABASE IF NOT EXISTS `{target_db}`") + sql = sql.replace("USE `talkingq`;", f"USE `{target_db}`;") + return sql + + +def execute_statements(connection: pymysql.Connection, statements: list[str]) -> None: + with connection.cursor() as cursor: + for index, statement in enumerate(statements, start=1): + try: + cursor.execute(statement) + except Exception as exc: # pragma: no cover - operational path + snippet = " ".join(statement.split())[:200] + raise RuntimeError(f"statement #{index} failed: {snippet}") from exc + + +def fetch_table_names(connection: pymysql.Connection, *, database: str) -> set[str]: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = %s + """, + (database,), + ) + return {row[0] for row in cursor.fetchall()} + + +def database_exists(connection: pymysql.Connection, *, database: str) -> bool: + with connection.cursor() as cursor: + cursor.execute("SHOW DATABASES LIKE %s", (database,)) + return cursor.fetchone() is not None + + +def drop_database(connection: pymysql.Connection, *, database: str) -> None: + with connection.cursor() as cursor: + cursor.execute(f"DROP DATABASE IF EXISTS `{database}`") + + +def print_summary(connection: pymysql.Connection, *, target_db: str) -> None: + tables = fetch_table_names(connection, database=target_db) + expected_tables = set(TALKINGQ_URL_TABLES + MINI_PROGRAM_TABLES) + missing_tables = sorted(expected_tables - tables) + + print(f"target database: {target_db}") + print(f"talkingq-url tables present: {len(set(TALKINGQ_URL_TABLES) & tables)}/{len(TALKINGQ_URL_TABLES)}") + print(f"mini-program tables present: {len(set(MINI_PROGRAM_TABLES) & tables)}/{len(MINI_PROGRAM_TABLES)}") + if missing_tables: + print("missing tables:") + for table_name in missing_tables: + print(f" - {table_name}") + raise SystemExit(1) + + +def main() -> int: + args = parse_args() + schema_path = Path(args.schema).resolve() + if not schema_path.exists(): + print(f"schema file not found: {schema_path}", file=sys.stderr) + return 1 + + if args.drop_legacy_db and args.legacy_db == args.target_db: + print("legacy database must be different from target database", file=sys.stderr) + return 1 + + schema_sql = load_schema_sql(schema_path, target_db=args.target_db) + statements = split_sql_statements(schema_sql) + if not statements: + print("schema file contains no executable statements", file=sys.stderr) + return 1 + + connection = pymysql.connect( + host=args.host, + port=args.port, + user=args.user, + password=args.password, + charset="utf8mb4", + autocommit=True, + ) + try: + print(f"applying shared schema to `{args.target_db}` using `{schema_path}`") + print(f"db connection target: {args.user}@{args.host}:{args.port}") + if args.env_file: + print(f"env fallback file: {Path(args.env_file).expanduser().resolve()}") + print("migration mode: talkingq-url data is preserved; legacy mini_program data is ignored") + execute_statements(connection, statements) + print_summary(connection, target_db=args.target_db) + + if args.drop_legacy_db: + if database_exists(connection, database=args.legacy_db): + drop_database(connection, database=args.legacy_db) + print(f"dropped legacy database: {args.legacy_db}") + else: + print(f"legacy database not found, skip drop: {args.legacy_db}") + else: + print(f"legacy database left untouched: {args.legacy_db}") + finally: + connection.close() + + print("shared database migration completed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/database/talkingq_shared_schema.sql b/database/talkingq_shared_schema.sql new file mode 100644 index 0000000..d694bf7 --- /dev/null +++ b/database/talkingq_shared_schema.sql @@ -0,0 +1,479 @@ +-- Banban shared schema +-- Sources: +-- 1. talkingq-url/mysql/init/01-init.sql +-- 2. mini-program/database/init.sql +-- Target database: talkingq +-- +-- Notes: +-- 1. talkingq-url base tables are kept as the device-domain foundation. +-- 2. mini-program business tables are merged into the same database. +-- 3. device_auth.device_id is the shared device identity key. +-- 4. Device <-> AI conversation records remain in +-- conversation_histories / conversation_messages. +-- 5. The database owner is shared, but table ownership is explicit: +-- talkingq-url owns device-domain base tables; mini-program owns +-- parent/child/binding/im/location extension tables. +-- 6. During migration to the shared database, talkingq-url data in +-- `talkingq` is the source of truth. Legacy `mini_program` data is +-- not merged into `talkingq`. + +CREATE DATABASE IF NOT EXISTS `talkingq` + DEFAULT CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE `talkingq`; + +-- --------------------------------------------------------------------------- +-- talkingq-url base tables +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `device_configs` ( + `id` INT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `selected_role_key` VARCHAR(64) NOT NULL, + `preferred_language` VARCHAR(10) NULL, + `volume` INT NULL, + `last_update_time` FLOAT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `device_id_UNIQUE` (`device_id` ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `conversation_histories` ( + `id` INT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `role_key` VARCHAR(64) NOT NULL, + `last_interaction_time` FLOAT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_device_id` (`device_id` ASC), + INDEX `idx_role_key` (`role_key` ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `conversation_messages` ( + `id` INT NOT NULL AUTO_INCREMENT, + `conversation_id` INT NOT NULL, + `is_user` TINYINT(1) NOT NULL DEFAULT 0, + `content` TEXT NOT NULL, + `timestamp` FLOAT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_conversation_id` (`conversation_id` ASC), + CONSTRAINT `fk_messages_conversation` + FOREIGN KEY (`conversation_id`) + REFERENCES `conversation_histories` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `roles` ( + `id` INT NOT NULL AUTO_INCREMENT, + `role_key` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `description` TEXT NULL, + `content` TEXT NOT NULL, + `default_language` VARCHAR(10) NULL, + `asr_provider` VARCHAR(64) NULL, + `llm_provider` VARCHAR(64) NULL, + `tts_provider` VARCHAR(64) NULL, + `competitive_llm_mode` TINYINT(1) NULL, + `volcano_model_id` VARCHAR(64) NULL, + `volcano_voice_type` VARCHAR(64) NULL, + `tencent_voice_type` VARCHAR(64) NULL, + `aliyun_voice_name` VARCHAR(64) NULL, + `minimax_voice_id` VARCHAR(64) NULL, + `url` VARCHAR(255) NULL, + `homophones` JSON NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `role_key_UNIQUE` (`role_key` ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `role_languages` ( + `id` INT NOT NULL AUTO_INCREMENT, + `role_id` INT NOT NULL, + `language_code` VARCHAR(10) NOT NULL, + `name` VARCHAR(128) NULL, + `content` TEXT NULL, + `asr_provider` VARCHAR(64) NULL, + `llm_provider` VARCHAR(64) NULL, + `tts_provider` VARCHAR(64) NULL, + `volcano_voice_type` VARCHAR(64) NULL, + `tencent_voice_type` VARCHAR(64) NULL, + `aliyun_voice_name` VARCHAR(64) NULL, + `minimax_voice_id` VARCHAR(64) NULL, + `url` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `uix_role_language` (`role_id`, `language_code`), + CONSTRAINT `fk_role_languages_role` + FOREIGN KEY (`role_id`) + REFERENCES `roles` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `device_auth` ( + `id` INT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `serial_number` VARCHAR(64) NOT NULL, + `batch_id` VARCHAR(20) NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `device_id_UNIQUE` (`device_id` ASC), + INDEX `idx_batch_id` (`batch_id` ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `device_firmware_update` ( + `id` INT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `serial_number` VARCHAR(64) NOT NULL, + `mac_address` VARCHAR(512) NULL, + `firmware_version` VARCHAR(64) NOT NULL, + `update_status` VARCHAR(32) NOT NULL DEFAULT 'success', + `progress` FLOAT NULL DEFAULT 0.0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `device_id_UNIQUE` (`device_id` ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `system_config` ( + `id` INT NOT NULL AUTO_INCREMENT, + `config_key` VARCHAR(128) NOT NULL, + `config_value` TEXT NULL, + `description` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `config_key_UNIQUE` (`config_key` ASC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `device_configs` (`device_id`, `selected_role_key`, `preferred_language`, `last_update_time`) +VALUES ('default', 'assistant', 'zh', UNIX_TIMESTAMP()) +ON DUPLICATE KEY UPDATE `selected_role_key` = `selected_role_key`; + +INSERT INTO `system_config` (`config_key`, `config_value`, `description`) +VALUES + ('latest_firmware_version', '1.0.0', '最新固件版本'), + ('update_firmware_url', 'https://example.com/firmware/latest.bin', '固件更新URL') +ON DUPLICATE KEY UPDATE `config_value` = `config_value`; + +INSERT INTO `roles` (`role_key`, `name`, `description`, `content`, `default_language`, `enabled`) +VALUES ( + 'assistant', + '智能助手', + '默认智能助手角色', + '你是一个友好的智能助手,乐于帮助用户解答问题。', + 'zh', + 1 +) +ON DUPLICATE KEY UPDATE `role_key` = `role_key`; + +-- --------------------------------------------------------------------------- +-- mini-program extension tables +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `parents` ( + `user_id` BIGINT NOT NULL AUTO_INCREMENT, + `openid` VARCHAR(64) NOT NULL, + `unionid` VARCHAR(64) NULL, + `nickname` VARCHAR(64) NULL, + `avatar_url` VARCHAR(255) NULL, + `avatar_file_key` VARCHAR(255) NULL, + `phone` VARCHAR(20) NULL, + `status` TINYINT NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`), + UNIQUE KEY `uq_parents_openid` (`openid`), + KEY `idx_parents_unionid` (`unionid`), + KEY `idx_parents_phone` (`phone`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `children` ( + `child_id` BIGINT NOT NULL AUTO_INCREMENT, + `child_name` VARCHAR(32) NOT NULL, + `child_gender` TINYINT NOT NULL DEFAULT 2, + `child_birthday` DATE NULL, + `status` TINYINT NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`child_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `parent_child_relations` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `child_id` BIGINT NOT NULL, + `relation_type` TINYINT NOT NULL DEFAULT 9, + `is_primary` TINYINT(1) NOT NULL DEFAULT 0, + `status` TINYINT NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `uq_user_child` UNIQUE (`user_id`, `child_id`), + KEY `idx_pcr_user_id` (`user_id`), + KEY `idx_pcr_child_id` (`child_id`), + CONSTRAINT `fk_pcr_user` + FOREIGN KEY (`user_id`) + REFERENCES `parents` (`user_id`) + ON DELETE CASCADE, + CONSTRAINT `fk_pcr_child` + FOREIGN KEY (`child_id`) + REFERENCES `children` (`child_id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `device_bindings` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `owner_user_id` BIGINT NOT NULL, + `child_id` BIGINT NULL, + `status` TINYINT NOT NULL DEFAULT 1, + `bound_at` DATETIME NOT NULL, + `unbound_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `uq_device_binding_device` UNIQUE (`device_id`), + CONSTRAINT `uq_device_binding_child` UNIQUE (`child_id`), + KEY `idx_device_bindings_owner_user_id` (`owner_user_id`), + CONSTRAINT `fk_device_bindings_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`), + CONSTRAINT `fk_device_bindings_owner` + FOREIGN KEY (`owner_user_id`) + REFERENCES `parents` (`user_id`), + CONSTRAINT `fk_device_bindings_child` + FOREIGN KEY (`child_id`) + REFERENCES `children` (`child_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `device_bind_sessions` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `bind_token` CHAR(36) NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `initiator_user_id` BIGINT NOT NULL, + `target_child_id` BIGINT NULL, + `challenge_code_hash` CHAR(64) NULL, + `challenge_set_at` DATETIME NULL, + `expires_at` DATETIME NOT NULL, + `max_attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 5, + `attempt_count` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `status` TINYINT NOT NULL DEFAULT 1, + `confirmed_at` DATETIME NULL, + `consumed_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_device_bind_sessions_token` (`bind_token`), + KEY `idx_device_bind_sessions_device_id` (`device_id`), + KEY `idx_device_bind_sessions_initiator_user_id` (`initiator_user_id`), + KEY `idx_device_bind_sessions_target_child_id` (`target_child_id`), + KEY `idx_device_bind_sessions_expires_at` (`expires_at`), + CONSTRAINT `fk_device_bind_sessions_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`), + CONSTRAINT `fk_device_bind_sessions_initiator` + FOREIGN KEY (`initiator_user_id`) + REFERENCES `parents` (`user_id`), + CONSTRAINT `fk_device_bind_sessions_target_child` + FOREIGN KEY (`target_child_id`) + REFERENCES `children` (`child_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `device_bind_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `child_id` BIGINT NULL, + `bound_by_user_id` BIGINT NOT NULL, + `unbound_by_user_id` BIGINT NULL, + `bind_source` TINYINT NOT NULL DEFAULT 1, + `bound_at` DATETIME NOT NULL, + `unbound_at` DATETIME NULL, + `unbind_reason` VARCHAR(191) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_device_bind_history_device_id` (`device_id`), + KEY `idx_device_bind_history_child_id` (`child_id`), + KEY `idx_device_bind_history_bound_by_user_id` (`bound_by_user_id`), + KEY `idx_device_bind_history_unbound_by_user_id` (`unbound_by_user_id`), + KEY `idx_device_bind_history_bound_at` (`bound_at`), + CONSTRAINT `fk_device_bind_history_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`), + CONSTRAINT `fk_device_bind_history_child` + FOREIGN KEY (`child_id`) + REFERENCES `children` (`child_id`) + ON DELETE SET NULL, + CONSTRAINT `fk_device_bind_history_bound_by` + FOREIGN KEY (`bound_by_user_id`) + REFERENCES `parents` (`user_id`), + CONSTRAINT `fk_device_bind_history_unbound_by` + FOREIGN KEY (`unbound_by_user_id`) + REFERENCES `parents` (`user_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cards` ( + `card_id` BIGINT NOT NULL AUTO_INCREMENT, + `card_uuid` VARCHAR(64) NOT NULL, + `device_id` VARCHAR(64) NULL, + `card_name` VARCHAR(64) NULL, + `status` TINYINT NOT NULL DEFAULT 0, + `total_swaps` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`card_id`), + UNIQUE KEY `uq_cards_card_uuid` (`card_uuid`), + UNIQUE KEY `uq_cards_device_id` (`device_id`), + KEY `idx_cards_status` (`status`), + CONSTRAINT `fk_cards_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Shared device volume is recommended to use device_configs.volume as source of truth. +-- device_settings.volume is retained for mini-program compatibility and UI storage. +CREATE TABLE IF NOT EXISTS `device_settings` ( + `setting_id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `sleep_mode` TINYINT NOT NULL DEFAULT 0, + `disable_time_start` TIME NULL, + `disable_time_end` TIME NULL, + `timezone` VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai', + `volume` TINYINT UNSIGNED NULL, + `brightness` TINYINT UNSIGNED NULL, + `disable_weekdays` VARCHAR(32) NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`setting_id`), + UNIQUE KEY `uq_device_settings_device_id` (`device_id`), + CONSTRAINT `fk_device_settings_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `im_conversations` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `conversation_type` TINYINT UNSIGNED NOT NULL, + `participant_a_type` TINYINT UNSIGNED NOT NULL, + `participant_a_id` VARCHAR(64) NOT NULL, + `participant_b_type` TINYINT UNSIGNED NOT NULL, + `participant_b_id` VARCHAR(64) NOT NULL, + `pair_key` VARCHAR(191) NOT NULL, + `status` TINYINT NOT NULL DEFAULT 1, + `last_seq` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `message_count` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `last_message_preview` VARCHAR(255) NULL, + `last_message_at` DATETIME NULL, + `ext_json` JSON NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `uq_conv_type_pair` UNIQUE (`conversation_type`, `pair_key`), + KEY `idx_im_conv_participant_a` (`participant_a_type`, `participant_a_id`), + KEY `idx_im_conv_participant_b` (`participant_b_type`, `participant_b_id`), + KEY `idx_im_conv_last_message_at` (`last_message_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `im_messages` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `conversation_id` BIGINT UNSIGNED NOT NULL, + `seq` BIGINT UNSIGNED NOT NULL, + `sender_type` TINYINT UNSIGNED NOT NULL, + `sender_id` VARCHAR(64) NOT NULL, + `receiver_type` TINYINT UNSIGNED NOT NULL, + `receiver_id` VARCHAR(64) NOT NULL, + `content_type` TINYINT UNSIGNED NOT NULL, + `content_text` MEDIUMTEXT NULL, + `content_json` JSON NULL, + `media_file_key` VARCHAR(255) NULL, + `media_duration_ms` INT UNSIGNED NULL, + `media_mime_type` VARCHAR(64) NULL, + `media_size_bytes` BIGINT UNSIGNED NULL, + `media_transcript_text` TEXT NULL, + `client_msg_id` VARCHAR(64) NULL, + `sender_name_snapshot` VARCHAR(64) NULL, + `sender_avatar_snapshot` VARCHAR(255) NULL, + `receiver_name_snapshot` VARCHAR(64) NULL, + `receiver_avatar_snapshot` VARCHAR(255) NULL, + `ext_json` JSON NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + CONSTRAINT `uq_im_msg_conv_seq` UNIQUE (`conversation_id`, `seq`), + CONSTRAINT `uq_im_msg_client` UNIQUE (`conversation_id`, `client_msg_id`), + KEY `idx_im_msg_conversation_created_at` (`conversation_id`, `created_at`), + KEY `idx_im_msg_sender` (`sender_type`, `sender_id`, `created_at`), + KEY `idx_im_msg_receiver` (`receiver_type`, `receiver_id`, `created_at`), + CONSTRAINT `fk_im_messages_conversation` + FOREIGN KEY (`conversation_id`) + REFERENCES `im_conversations` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `child_location_current` ( + `child_id` BIGINT NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `coord_type` VARCHAR(16) NOT NULL DEFAULT 'gcj02', + `lat` DECIMAL(10, 7) NOT NULL, + `lng` DECIMAL(10, 7) NOT NULL, + `accuracy_m` INT UNSIGNED NULL, + `altitude_m` DECIMAL(8, 2) NULL, + `speed_mps` DECIMAL(8, 2) NULL, + `heading_deg` SMALLINT UNSIGNED NULL, + `source` TINYINT UNSIGNED NOT NULL, + `battery_pct` TINYINT UNSIGNED NULL, + `device_time` DATETIME NOT NULL, + `server_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`child_id`), + KEY `idx_child_loc_current_device` (`device_id`), + KEY `idx_child_loc_current_server_time` (`server_time`), + CONSTRAINT `fk_child_loc_current_child` + FOREIGN KEY (`child_id`) + REFERENCES `children` (`child_id`) + ON DELETE CASCADE, + CONSTRAINT `fk_child_loc_current_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `child_location_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `child_id` BIGINT NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `coord_type` VARCHAR(16) NOT NULL DEFAULT 'gcj02', + `lat` DECIMAL(10, 7) NOT NULL, + `lng` DECIMAL(10, 7) NOT NULL, + `accuracy_m` INT UNSIGNED NULL, + `altitude_m` DECIMAL(8, 2) NULL, + `speed_mps` DECIMAL(8, 2) NULL, + `heading_deg` SMALLINT UNSIGNED NULL, + `source` TINYINT UNSIGNED NOT NULL, + `battery_pct` TINYINT UNSIGNED NULL, + `device_time` DATETIME NOT NULL, + `server_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_child_loc_hist_child` (`child_id`, `created_at`), + KEY `idx_child_loc_hist_device` (`device_id`, `created_at`), + CONSTRAINT `fk_child_loc_hist_child` + FOREIGN KEY (`child_id`) + REFERENCES `children` (`child_id`) + ON DELETE CASCADE, + CONSTRAINT `fk_child_loc_hist_device` + FOREIGN KEY (`device_id`) + REFERENCES `device_auth` (`device_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mini-program/.env.example b/mini-program/.env.example index bc12163..65e0473 100644 --- a/mini-program/.env.example +++ b/mini-program/.env.example @@ -9,14 +9,22 @@ DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=root DB_PASSWORD=change_me -DB_NAME=mini_program +DB_NAME=talkingq +DB_AUTO_INIT_TABLES=false DB_PATH=./data.db +WECHAT_APP_ID=wx_change_me +WECHAT_APP_SECRET=change_me +WECHAT_API_BASE_URL=https://api.weixin.qq.com +WECHAT_HTTP_TIMEOUT_SECONDS=5 COS_SECRET_ID=change_me COS_SECRET_KEY=change_me COS_REGION=ap-guangzhou -COS_BUCKET=voicemessage-1320289366 -COS_PUBLIC_BASE_URL=https://voicemessage-1320289366.cos.ap-guangzhou.myqcloud.com -COS_AUDIO_PREFIX=voiceMessage/ +COS_BUCKET_MESSAGE=message-1320289366 +COS_BUCKET_AVA=ava-1320289366 +COS_PUBLIC_BASE_URL= +COS_AVATAR_PREFIX=avatars/ +COS_AVATAR_URL_EXPIRE_SECONDS=86400 +COS_AVATAR_MAX_BYTES=2097152 JWT_SECRET=change_me_to_a_long_random_string JWT_ALGORITHM=HS256 JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/mini-program/app/dao/binding.py b/mini-program/app/dao/binding.py index 87bfa00..6e18a17 100644 --- a/mini-program/app/dao/binding.py +++ b/mini-program/app/dao/binding.py @@ -13,6 +13,55 @@ logger = logging.getLogger("app.dao.binding") class BindingDAO(BaseDAO): + def get_device_auth(self, device_id: str) -> Optional[Mapping]: + return ( + self.db.execute( + text( + """ + SELECT device_id, serial_number, is_active + FROM device_auth + WHERE device_id = :device_id + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + .mappings() + .first() + ) + + def _clear_child_from_binding(self, binding_row: Mapping[str, object]) -> None: + row_id = int(binding_row["id"]) + status = int(binding_row["status"]) + + if status == 1: + self.db.execute( + text( + """ + UPDATE device_bindings + SET child_id = NULL, + status = 1, + unbound_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """ + ), + {"id": row_id}, + ) + return + + self.db.execute( + text( + """ + UPDATE device_bindings + SET child_id = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """ + ), + {"id": row_id}, + ) + def _upsert_parent_child_relation(self, user_id: int, child_id: int) -> None: updated = self.db.execute( text( @@ -73,7 +122,7 @@ class BindingDAO(BaseDAO): def _bind_device(self, device_id: str, user_id: int, child_id: Optional[int]) -> None: existing_by_device = ( self.db.execute( - text("SELECT id FROM device_bindings WHERE device_id = :device_id"), + text("SELECT id, status FROM device_bindings WHERE device_id = :device_id"), {"device_id": device_id}, ) .mappings() @@ -110,7 +159,7 @@ class BindingDAO(BaseDAO): existing_by_child = ( self.db.execute( - text("SELECT id FROM device_bindings WHERE child_id = :child_id"), + text("SELECT id, status FROM device_bindings WHERE child_id = :child_id"), {"child_id": child_id}, ) .mappings() @@ -120,20 +169,8 @@ class BindingDAO(BaseDAO): if existing_by_device: device_row_id = int(existing_by_device["id"]) if existing_by_child and int(existing_by_child["id"]) != device_row_id: - # Release child ownership from a different row before assigning to this device. - self.db.execute( - text( - """ - UPDATE device_bindings - SET child_id = NULL, - status = 0, - unbound_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE id = :id - """ - ), - {"id": existing_by_child["id"]}, - ) + # Keep the old device row visible; only release its child assignment. + self._clear_child_from_binding(existing_by_child) self.db.execute( text( @@ -146,27 +183,14 @@ class BindingDAO(BaseDAO): updated_at = CURRENT_TIMESTAMP WHERE device_id = :device_id """ - ), - {"owner_user_id": user_id, "child_id": child_id, "device_id": device_id}, - ) + ), + {"owner_user_id": user_id, "child_id": child_id, "device_id": device_id}, + ) return if existing_by_child: - self.db.execute( - text( - """ - UPDATE device_bindings - SET device_id = :device_id, - owner_user_id = :owner_user_id, - status = 1, - unbound_at = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE id = :id - """ - ), - {"device_id": device_id, "owner_user_id": user_id, "id": existing_by_child["id"]}, - ) - return + # Do not overwrite the previous device row; keep it as an unbound device. + self._clear_child_from_binding(existing_by_child) self.db.execute( text( @@ -252,6 +276,40 @@ class BindingDAO(BaseDAO): .first() ) + def list_by_user(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: + params = {"user_id": user_id, "limit": limit + 1} + where = "db.owner_user_id = :user_id AND db.status = 1" + if cursor is not None: + where += " AND db.id < :cursor" + params["cursor"] = cursor + + rows = ( + self.db.execute( + text( + f""" + SELECT + db.id, + db.device_id, + db.child_id, + c.child_name, + db.status, + db.bound_at + FROM device_bindings AS db + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE {where} + ORDER BY db.id DESC + LIMIT :limit + """ + ), + params, + ) + .mappings() + .all() + ) + return rows + def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]: return ( self.db.execute( diff --git a/mini-program/app/dao/child.py b/mini-program/app/dao/child.py index 2a46201..046bf2c 100644 --- a/mini-program/app/dao/child.py +++ b/mini-program/app/dao/child.py @@ -5,9 +5,21 @@ from typing import Optional from sqlalchemy import text from app.dao import BaseDAO +from app.db_compat import inserted_primary_key class ChildDAO(BaseDAO): + def _create_relation(self, user_id: int, child_id: int) -> None: + self.db.execute( + text( + """ + INSERT INTO parent_child_relations (user_id, child_id, relation_type, is_primary, status) + VALUES (:user_id, :child_id, 9, 0, 1) + """ + ), + {"user_id": user_id, "child_id": child_id}, + ) + def create( self, user_id: int, @@ -18,13 +30,14 @@ class ChildDAO(BaseDAO): result = self.db.execute( text( """ - INSERT INTO children (parent_user_id, child_name, child_gender, child_birthday, status) - VALUES (:user_id, :child_name, :child_gender, :child_birthday, 1) + INSERT INTO children (child_name, child_gender, child_birthday, status) + VALUES (:child_name, :child_gender, :child_birthday, 1) """ ), - {"user_id": user_id, "child_name": child_name, "child_gender": child_gender, "child_birthday": child_birthday}, + {"child_name": child_name, "child_gender": child_gender, "child_birthday": child_birthday}, ) - child_id = int(result.lastrowid) + child_id = inserted_primary_key(result) + self._create_relation(user_id, child_id) self.commit() return child_id @@ -40,18 +53,21 @@ class ChildDAO(BaseDAO): def list_by_parent(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: params = {"user_id": user_id, "limit": limit + 1} - where = "parent_user_id = :user_id AND status = 1" - if cursor: - where += " AND child_id < :cursor" + where = "pcr.user_id = :user_id AND pcr.status = 1 AND c.status = 1" + if cursor is not None: + where += " AND c.child_id < :cursor" params["cursor"] = cursor rows = ( self.db.execute( text( f""" - SELECT * FROM children + SELECT c.* + FROM children AS c + JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id WHERE {where} - ORDER BY child_id DESC + ORDER BY c.child_id DESC LIMIT :limit """ ), @@ -87,9 +103,18 @@ class ChildDAO(BaseDAO): return ( self.db.execute( text( - "SELECT 1 FROM children WHERE child_id = :child_id AND parent_user_id = :user_id AND status = 1" + """ + SELECT 1 + FROM children AS c + JOIN parent_child_relations AS pcr + ON pcr.child_id = c.child_id + WHERE c.child_id = :child_id + AND pcr.user_id = :user_id + AND c.status = 1 + AND pcr.status = 1 + """ ), {"child_id": child_id, "user_id": user_id}, ).scalar_one_or_none() is not None - ) \ No newline at end of file + ) diff --git a/mini-program/app/dao/parent.py b/mini-program/app/dao/parent.py index fa1ee36..556792b 100644 --- a/mini-program/app/dao/parent.py +++ b/mini-program/app/dao/parent.py @@ -2,8 +2,10 @@ from collections.abc import Mapping from typing import Optional from sqlalchemy import text +from sqlalchemy.exc import IntegrityError from app.dao import BaseDAO +from app.db_compat import inserted_primary_key class ParentDAO(BaseDAO): @@ -23,8 +25,9 @@ class ParentDAO(BaseDAO): ), {"openid": openid, "unionid": unionid, "nickname": nickname, "avatar_url": avatar_url}, ) + user_id = inserted_primary_key(result) self.commit() - return int(self.db.execute(text("SELECT last_insert_rowid()")).scalar_one()) + return user_id def get_by_id(self, user_id: int) -> Optional[Mapping]: return ( @@ -67,6 +70,49 @@ class ParentDAO(BaseDAO): ) self.commit() + def set_avatar_file_key(self, user_id: int, avatar_file_key: str) -> None: + self.db.execute( + text( + """ + UPDATE parents + SET avatar_file_key = :avatar_file_key, + avatar_url = NULL + WHERE user_id = :user_id + """ + ), + {"user_id": user_id, "avatar_file_key": avatar_file_key}, + ) + self.commit() + + def update_from_wechat_login( + self, + user_id: int, + unionid: Optional[str] = None, + nickname: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> None: + self.db.execute( + text( + """ + UPDATE parents + SET unionid = CASE + WHEN unionid IS NULL AND :unionid IS NOT NULL THEN :unionid + ELSE unionid + END, + nickname = COALESCE(:nickname, nickname), + avatar_url = COALESCE(:avatar_url, avatar_url) + WHERE user_id = :user_id + """ + ), + { + "user_id": user_id, + "unionid": unionid, + "nickname": nickname, + "avatar_url": avatar_url, + }, + ) + self.commit() + def upsert( self, openid: str, @@ -74,27 +120,18 @@ class ParentDAO(BaseDAO): nickname: Optional[str] = None, avatar_url: Optional[str] = None, ) -> int: - from app.settings import settings - existing = self.get_by_openid(openid) if existing: - self.update(existing["user_id"], nickname, avatar_url) - return existing["user_id"] + self.update_from_wechat_login(existing["user_id"], unionid, nickname, avatar_url) + return int(existing["user_id"]) - if settings.db_type == "sqlite": + try: return self.create(openid, unionid, nickname, avatar_url) - - self.db.execute( - text( - """ - INSERT INTO parents (openid, unionid, nickname, avatar_url, status) - VALUES (:openid, :unionid, :nickname, :avatar_url, 1) - ON DUPLICATE KEY UPDATE - user_id = LAST_INSERT_ID(user_id), - updated_at = CURRENT_TIMESTAMP - """ - ), - {"openid": openid, "unionid": unionid, "nickname": nickname, "avatar_url": avatar_url}, - ) - self.commit() - return int(self.db.execute(text("SELECT LAST_INSERT_ID()")).scalar_one()) \ No newline at end of file + except IntegrityError: + self.db.rollback() + existing = self.get_by_openid(openid) + if not existing: + raise + if unionid or nickname or avatar_url: + self.update_from_wechat_login(existing["user_id"], unionid, nickname, avatar_url) + return int(existing["user_id"]) diff --git a/mini-program/app/db.py b/mini-program/app/db.py index 007a0d1..82f540e 100644 --- a/mini-program/app/db.py +++ b/mini-program/app/db.py @@ -2,7 +2,7 @@ from collections.abc import Generator import logging import time -from sqlalchemy import create_engine, event, text +from sqlalchemy import bindparam, create_engine, event, text from sqlalchemy.orm import Session, sessionmaker try: @@ -43,6 +43,29 @@ def set_sqlite_pragma(dbapi_conn, connection_record): logger = logging.getLogger("app.db") +SHARED_TALKINGQ_REQUIRED_TABLES = ( + "device_auth", + "device_configs", + "conversation_histories", + "conversation_messages", + "roles", + "role_languages", + "device_firmware_update", + "system_config", + "parents", + "children", + "parent_child_relations", + "device_bindings", + "device_bind_sessions", + "device_bind_history", + "cards", + "device_settings", + "im_conversations", + "im_messages", + "child_location_current", + "child_location_history", +) + @event.listens_for(engine, "before_cursor_execute") def before_cursor_execute( @@ -112,6 +135,46 @@ def check_db_connection() -> None: logger.info("database connection check succeeded", extra={"event": "db_check"}) +def check_shared_schema_tables() -> None: + if not settings.uses_shared_talkingq_db: + return + + query = text( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = :table_schema + AND table_name IN :table_names + """ + ).bindparams(bindparam("table_names", expanding=True)) + + with engine.connect() as conn: + rows = conn.execute( + query, + { + "table_schema": settings.db_name, + "table_names": list(SHARED_TALKINGQ_REQUIRED_TABLES), + }, + ) + existing_tables = {row[0] for row in rows} + + missing_tables = sorted(set(SHARED_TALKINGQ_REQUIRED_TABLES) - existing_tables) + if missing_tables: + raise RuntimeError( + "shared talkingq schema is incomplete; missing tables: " + + ", ".join(missing_tables) + ) + + logger.info( + "shared talkingq schema check succeeded", + extra={ + "event": "db_shared_schema_check", + "db_name": settings.db_name, + "table_count": len(SHARED_TALKINGQ_REQUIRED_TABLES), + }, + ) + + def close_db_engine() -> None: engine.dispose() logger.info("database engine disposed", extra={"event": "db_close"}) diff --git a/mini-program/app/db_compat.py b/mini-program/app/db_compat.py new file mode 100644 index 0000000..d3d2260 --- /dev/null +++ b/mini-program/app/db_compat.py @@ -0,0 +1,41 @@ +from collections.abc import Sequence +from typing import Any + +from sqlalchemy.orm import Session + + +def get_db_dialect_name(db: Session) -> str: + bind = db.get_bind() + if bind is None: + raise RuntimeError("Database session is not bound to an engine") + return bind.dialect.name + + +def current_timestamp_sql(db: Session) -> str: + if get_db_dialect_name(db) == "mysql": + return "CURRENT_TIMESTAMP(3)" + return "CURRENT_TIMESTAMP" + + +def select_for_update_clause(db: Session) -> str: + if get_db_dialect_name(db) == "mysql": + return " FOR UPDATE" + return "" + + +def inserted_primary_key(result: Any) -> int: + lastrowid = getattr(result, "lastrowid", None) + if lastrowid is not None: + return int(lastrowid) + + try: + inserted_primary_key = result.inserted_primary_key + except Exception: + inserted_primary_key = None + + if isinstance(inserted_primary_key, Sequence) and inserted_primary_key: + primary_key = inserted_primary_key[0] + if primary_key is not None: + return int(primary_key) + + raise RuntimeError("Could not determine inserted primary key") diff --git a/mini-program/app/main.py b/mini-program/app/main.py index 8279e80..2b59702 100644 --- a/mini-program/app/main.py +++ b/mini-program/app/main.py @@ -1,35 +1,37 @@ import os import logging +import sys +from pathlib import Path from fastapi import FastAPI -try: - # For module mode: `uvicorn app.main:app` - from app.db import check_db_connection, close_db_engine, init_db_tables - from app.logging_setup import configure_logging - from app.middleware.auth import install_auth_middleware - from app.middleware.request_log import install_request_logging_middleware - from app.routers.auth import router as auth_router - from app.routers.wechat_auth import router as wechat_auth_router - from app.routers.health import router as health_router - from app.routers.messages import router as messages_router - from app.routers.parents import router as parents_router - from app.routers.children import router as children_router - from app.routers.bindings import router as bindings_router - from app.settings import settings -except ModuleNotFoundError: - # For script mode: `python app/main.py` or VS Code "Run Python File" - from db import check_db_connection, close_db_engine, init_db_tables - from logging_setup import configure_logging - from middleware.auth import install_auth_middleware - from middleware.request_log import install_request_logging_middleware - from routers.auth import router as auth_router - from routers.health import router as health_router - from routers.messages import router as messages_router - from routers.parents import router as parents_router - from routers.children import router as children_router - from routers.bindings import router as bindings_router - from settings import settings +if __package__ in (None, ""): + # Make `python app/main.py` behave like module execution from the project root. + project_root = Path(__file__).resolve().parent.parent + project_root_str = str(project_root) + if project_root_str not in sys.path: + sys.path.insert(0, project_root_str) + +from app.db import ( + check_db_connection, + check_shared_schema_tables, + close_db_engine, + init_db_tables, +) +from app.logging_setup import configure_logging +from app.middleware.auth import install_auth_middleware +from app.middleware.request_log import install_request_logging_middleware +from app.routers.auth import router as auth_router +from app.routers.wechat_auth import router as wechat_auth_router +from app.routers.health import router as health_router +from app.routers.parents import router as parents_router +from app.routers.children import router as children_router +from app.routers.bindings import router as bindings_router +from app.routers.devices import router as devices_router +from app.routers.device_im import router as device_im_router +from app.routers.device_location import router as device_location_router +from app.routers.im import router as im_router +from app.settings import settings configure_logging() @@ -46,7 +48,25 @@ def create_app() -> FastAPI: @app.on_event("startup") def on_startup() -> None: check_db_connection() - init_db_tables() + if settings.db_auto_init_tables: + init_db_tables() + if settings.uses_shared_talkingq_db: + logger.warning( + "database auto init only creates mini-program tables; shared deployments should use the shared schema migration script", + extra={ + "event": "db_shared_auto_init_warning", + "db_name": settings.db_name, + }, + ) + else: + check_shared_schema_tables() + logger.info( + "database auto init disabled", + extra={ + "event": "db_init_skipped", + "db_name": settings.db_name, + }, + ) logger.info("application started", extra={"event": "app_start"}) @app.on_event("shutdown") @@ -58,13 +78,13 @@ def create_app() -> FastAPI: install_request_logging_middleware(app) app.include_router(wechat_auth_router) app.include_router(health_router) - app.include_router(messages_router) - try: - app.include_router(parents_router) - app.include_router(children_router) - app.include_router(bindings_router) - except NameError: - pass + app.include_router(parents_router) + app.include_router(children_router) + app.include_router(bindings_router) + app.include_router(devices_router) + app.include_router(device_im_router) + app.include_router(device_location_router) + app.include_router(im_router) return app diff --git a/mini-program/app/middleware/auth.py b/mini-program/app/middleware/auth.py index 278f323..f4749df 100644 --- a/mini-program/app/middleware/auth.py +++ b/mini-program/app/middleware/auth.py @@ -20,6 +20,8 @@ NO_AUTH_PATH_PREFIXES = ( "/redoc", "/openapi.json", "/auth/login", + "/device-im", + "/device-location", ) diff --git a/mini-program/app/models/entities.py b/mini-program/app/models/entities.py index 08a0233..470a41b 100644 --- a/mini-program/app/models/entities.py +++ b/mini-program/app/models/entities.py @@ -30,6 +30,7 @@ class Parent(Base): unionid: Mapped[Optional[str]] = mapped_column(String(64)) nickname: Mapped[Optional[str]] = mapped_column(String(64)) avatar_url: Mapped[Optional[str]] = mapped_column(String(255)) + avatar_file_key: Mapped[Optional[str]] = mapped_column(String(255)) phone: Mapped[Optional[str]] = mapped_column(String(20)) status: Mapped[int] = mapped_column(Integer, server_default="1") created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) diff --git a/mini-program/app/routers/auth.py b/mini-program/app/routers/auth.py index 248ea13..cdf12bd 100644 --- a/mini-program/app/routers/auth.py +++ b/mini-program/app/routers/auth.py @@ -8,11 +8,13 @@ from sqlalchemy.orm import Session try: # For module mode: `uvicorn app.main:app` from app.db import get_db + from app.db_compat import current_timestamp_sql from app.schemas.auth import LoginRequest, LoginResponse from app.security import create_access_token except ModuleNotFoundError: # For script mode: `python app/main.py` or VS Code "Run Python File" from db import get_db + from db_compat import current_timestamp_sql from schemas.auth import LoginRequest, LoginResponse from security import create_access_token @@ -34,6 +36,7 @@ def login( db: Session = Depends(get_db), ) -> LoginResponse: username_masked = _mask_username(payload.username) + now_sql = current_timestamp_sql(db) with db.begin(): row = ( db.execute( @@ -107,10 +110,10 @@ def login( db.execute( text( - """ + f""" UPDATE chat_user_auth - SET last_login_at = CURRENT_TIMESTAMP(3), - updated_at = CURRENT_TIMESTAMP(3) + SET last_login_at = {now_sql}, + updated_at = {now_sql} WHERE id = :user_auth_id """ ), @@ -118,10 +121,10 @@ def login( ) db.execute( text( - """ + f""" UPDATE chat_user - SET last_login_at = CURRENT_TIMESTAMP(3), - updated_at = CURRENT_TIMESTAMP(3) + SET last_login_at = {now_sql}, + updated_at = {now_sql} WHERE id = :user_id """ ), diff --git a/mini-program/app/routers/bindings.py b/mini-program/app/routers/bindings.py index 8fffd5a..1961d2f 100644 --- a/mini-program/app/routers/bindings.py +++ b/mini-program/app/routers/bindings.py @@ -1,16 +1,16 @@ import logging from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel try: from app.security import get_current_user_id - from app.service.binding import BindingService + from app.service.binding import BindingError, BindingService from app.service import get_db_session except ModuleNotFoundError: from security import get_current_user_id - from service.binding import BindingService + from service.binding import BindingError, BindingService from service import get_db_session @@ -20,6 +20,7 @@ logger = logging.getLogger("app.bindings") class BindStartRequest(BaseModel): device_id: str + serial_number: str child_id: int | None = None @@ -45,6 +46,20 @@ class BindingGetResponse(BaseModel): bound_at: datetime +class BindingListItem(BaseModel): + device_id: str + child_id: int | None + child_name: str | None = None + status: int + bound_at: datetime + + +class BindingListResponse(BaseModel): + items: list[BindingListItem] + total: int + next_cursor: int | None = None + + class BindHistoryItem(BaseModel): device_id: str child_id: int | None @@ -66,7 +81,15 @@ def start_bind( db=Depends(get_db_session), ) -> BindStartResponse: service = BindingService(db) - bind_token, expires_at = service.start_bind(current_user_id, payload.device_id, payload.child_id) + try: + bind_token, expires_at = service.start_bind( + current_user_id, + payload.device_id, + payload.serial_number, + payload.child_id, + ) + except BindingError as e: + raise HTTPException(status_code=e.status_code, detail=str(e)) return BindStartResponse(bind_token=bind_token, expires_at=expires_at.isoformat()) @@ -80,6 +103,8 @@ def confirm_bind( service = BindingService(db) try: result = service.confirm_bind(payload.bind_token, current_user_id) + except BindingError as e: + raise HTTPException(status_code=e.status_code, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return BindConfirmResponse(**result) @@ -87,6 +112,7 @@ def confirm_bind( class DirectBindRequest(BaseModel): device_id: str + serial_number: str child_id: int | None = None @@ -107,7 +133,15 @@ def direct_bind( db=Depends(get_db_session), ) -> DirectBindResponse: service = BindingService(db) - result = service.direct_bind(payload.device_id, payload.child_id, current_user_id) + try: + result = service.direct_bind( + payload.device_id, + payload.serial_number, + payload.child_id, + current_user_id, + ) + except BindingError as e: + raise HTTPException(status_code=e.status_code, detail=str(e)) return DirectBindResponse(**result) @@ -122,6 +156,8 @@ def set_binding_child( service = BindingService(db) try: result = service.set_binding_child(device_id=device_id, child_id=payload.child_id, user_id=current_user_id) + except BindingError as e: + raise HTTPException(status_code=e.status_code, detail=str(e)) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return DirectBindResponse(**result) @@ -140,6 +176,30 @@ def get_current_binding( return binding +@router.get("", response_model=BindingListResponse) +def list_bindings( + request: Request, + cursor: int | None = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), + db=Depends(get_db_session), +) -> BindingListResponse: + service = BindingService(db) + rows, has_more = service.list_bindings(current_user_id, limit, cursor) + next_cursor = int(rows[-1]["id"]) if has_more and rows else None + items = [ + BindingListItem( + device_id=row["device_id"], + child_id=row["child_id"], + child_name=row["child_name"], + status=row["status"], + bound_at=row["bound_at"], + ) + for row in rows + ] + return BindingListResponse(items=items, total=len(items), next_cursor=next_cursor) + + @router.get("/{device_id}", response_model=BindingGetResponse) def get_binding( device_id: str, diff --git a/mini-program/app/routers/device_im.py b/mini-program/app/routers/device_im.py new file mode 100644 index 0000000..0ba7dfb --- /dev/null +++ b/mini-program/app/routers/device_im.py @@ -0,0 +1,305 @@ +import logging +from typing import Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status +from sqlalchemy import text +from sqlalchemy.orm import Session + +try: + from app.db import get_db + from app.routers.im import ( + CHILD_PARTICIPANT_TYPE, + PARENT_PARTICIPANT_TYPE, + SUPPORTED_CONVERSATION_TYPES, + _fetch_child_names, + _fetch_parent_names, + _get_conversation_for_child, + _row_to_conversation_item, + _row_to_message_item, + ) + from app.schemas.im import ( + ChildConversationListResponse, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + DeviceMessageCreateRequest, + ) + from app.service.im import authenticate_device_identity, create_device_message +except ModuleNotFoundError: + from db import get_db + from routers.im import ( + CHILD_PARTICIPANT_TYPE, + PARENT_PARTICIPANT_TYPE, + SUPPORTED_CONVERSATION_TYPES, + _fetch_child_names, + _fetch_parent_names, + _get_conversation_for_child, + _row_to_conversation_item, + _row_to_message_item, + ) + from schemas.im import ( + ChildConversationListResponse, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + DeviceMessageCreateRequest, + ) + from service.im import authenticate_device_identity, create_device_message + + +router = APIRouter(prefix="/device-im", tags=["device-im"]) +logger = logging.getLogger("app.device_im") + + +@router.get("/{device_id}/conversations", response_model=ChildConversationListResponse) +def list_device_conversations( + device_id: str, + request: Request, + device_serial: str = Header(alias="X-Device-Serial", min_length=1), + conversation_type: int | None = Query(default=None), + cursor: int | None = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +) -> ChildConversationListResponse: + if conversation_type is not None and conversation_type not in SUPPORTED_CONVERSATION_TYPES: + raise HTTPException(status_code=422, detail="unsupported conversation_type") + + device_identity = authenticate_device_identity( + db=db, + device_id=device_id, + serial_number=device_serial, + ) + child_id = device_identity.child_id + + params: dict[str, Any] = { + "child_id_str": str(child_id), + "child_participant_type": CHILD_PARTICIPANT_TYPE, + "fetch_limit": limit + 1, + } + where = """ + status = 1 + AND conversation_type IN (1, 2) + AND ( + (participant_a_type = :child_participant_type AND participant_a_id = :child_id_str) + OR (participant_b_type = :child_participant_type AND participant_b_id = :child_id_str) + ) + """ + if conversation_type is not None: + where += " AND conversation_type = :conversation_type" + params["conversation_type"] = conversation_type + if cursor is not None: + where += " AND id < :cursor" + params["cursor"] = cursor + + rows = ( + db.execute( + text( + f""" + SELECT + id, + conversation_type, + participant_a_type, + participant_a_id, + participant_b_type, + participant_b_id, + last_message_preview, + last_message_at, + message_count, + created_at + FROM im_conversations + WHERE {where} + ORDER BY COALESCE(last_message_at, created_at) DESC, id DESC + LIMIT :fetch_limit + """ + ), + params, + ) + .mappings() + .all() + ) + + has_more = len(rows) > limit + rows = rows[:limit] + next_cursor = int(rows[-1]["id"]) if has_more and rows else None + + peer_parent_ids: set[int] = set() + peer_child_ids: set[int] = set() + child_id_str = str(child_id) + for row in rows: + participant_a_type = int(row["participant_a_type"]) + participant_b_type = int(row["participant_b_type"]) + participant_a_id = str(row["participant_a_id"]) + participant_b_id = str(row["participant_b_id"]) + if participant_a_type == CHILD_PARTICIPANT_TYPE and participant_a_id == child_id_str: + peer_type = participant_b_type + peer_id = participant_b_id + else: + peer_type = participant_a_type + peer_id = participant_a_id + + if peer_id.isdigit(): + if peer_type == PARENT_PARTICIPANT_TYPE: + peer_parent_ids.add(int(peer_id)) + elif peer_type == CHILD_PARTICIPANT_TYPE: + peer_child_ids.add(int(peer_id)) + + parent_names = _fetch_parent_names(db, peer_parent_ids) + child_names = _fetch_child_names(db, peer_child_ids) + items = [ + _row_to_conversation_item( + row, + child_id=child_id, + parent_names=parent_names, + child_names=child_names, + ) + for row in rows + ] + + logger.info( + "device conversations listed", + extra={ + "event": "device_conversation_list", + "request_id": getattr(request.state, "request_id", None), + "device_id": device_id, + "child_id": child_id, + "conversation_type": conversation_type, + "count": len(items), + }, + ) + + return ChildConversationListResponse( + items=items, + total=len(items), + next_cursor=next_cursor, + ) + + +@router.get( + "/{device_id}/conversations/{conversation_id}/messages", + response_model=ChildConversationMessageListResponse, +) +def list_device_conversation_messages( + device_id: str, + conversation_id: int, + request: Request, + device_serial: str = Header(alias="X-Device-Serial", min_length=1), + cursor_seq: int | None = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +) -> ChildConversationMessageListResponse: + device_identity = authenticate_device_identity( + db=db, + device_id=device_id, + serial_number=device_serial, + ) + _get_conversation_for_child( + db=db, + conversation_id=conversation_id, + child_id=device_identity.child_id, + ) + + sql = """ + SELECT + id, + conversation_id, + seq, + sender_type, + sender_id, + receiver_type, + receiver_id, + content_type, + content_text, + content_json, + media_file_key, + media_duration_ms, + media_mime_type, + media_size_bytes, + media_transcript_text, + client_msg_id, + sender_name_snapshot, + sender_avatar_snapshot, + receiver_name_snapshot, + receiver_avatar_snapshot, + ext_json, + created_at + FROM im_messages + WHERE conversation_id = :conversation_id + AND deleted_at IS NULL + """ + params: dict[str, Any] = { + "conversation_id": conversation_id, + "fetch_limit": limit + 1, + } + if cursor_seq is not None: + sql += " AND seq < :cursor_seq" + params["cursor_seq"] = cursor_seq + sql += " ORDER BY seq DESC LIMIT :fetch_limit" + + rows = db.execute(text(sql), params).mappings().all() + has_more = len(rows) > limit + rows = rows[:limit] + rows.reverse() + items = [_row_to_message_item(row) for row in rows] + next_cursor_seq = items[0].seq if has_more and items else None + + logger.info( + "device conversation messages listed", + extra={ + "event": "device_conversation_message_list", + "request_id": getattr(request.state, "request_id", None), + "device_id": device_id, + "child_id": device_identity.child_id, + "conversation_id": conversation_id, + "count": len(items), + "has_more": has_more, + }, + ) + + return ChildConversationMessageListResponse( + conversation_id=conversation_id, + has_more=has_more, + next_cursor_seq=next_cursor_seq, + items=items, + ) + + +@router.post( + "/{device_id}/messages", + response_model=ConversationMessageCreateResponse, + status_code=status.HTTP_201_CREATED, +) +def create_message_from_device( + device_id: str, + payload: DeviceMessageCreateRequest, + request: Request, + response: Response, + device_serial: str = Header(alias="X-Device-Serial", min_length=1), + db: Session = Depends(get_db), +) -> ConversationMessageCreateResponse: + device_identity, result = create_device_message( + db=db, + device_id=device_id, + serial_number=device_serial, + payload=payload, + ) + if result.idempotent: + response.status_code = status.HTTP_200_OK + + logger.info( + "device message created", + extra={ + "event": "device_message_create", + "request_id": getattr(request.state, "request_id", None), + "device_id": device_id, + "child_id": device_identity.child_id, + "conversation_id": result.conversation_id, + "conversation_type": result.conversation_type, + "idempotent": result.idempotent, + }, + ) + + return ConversationMessageCreateResponse( + idempotent=result.idempotent, + conversation_id=result.conversation_id, + conversation_type=result.conversation_type, + conversation_type_name=result.conversation_type_name, + message=result.message, + ) diff --git a/mini-program/app/routers/device_location.py b/mini-program/app/routers/device_location.py new file mode 100644 index 0000000..eafed1c --- /dev/null +++ b/mini-program/app/routers/device_location.py @@ -0,0 +1,63 @@ +import logging + +from fastapi import APIRouter, Depends, Header, Request +from sqlalchemy.orm import Session + +try: + from app.db import get_db + from app.schemas.location import DeviceLocationReportRequest, DeviceLocationReportResponse + from app.service.location import report_device_location +except ModuleNotFoundError: + from db import get_db + from schemas.location import DeviceLocationReportRequest, DeviceLocationReportResponse + from service.location import report_device_location + + +router = APIRouter(prefix="/device-location", tags=["device-location"]) +logger = logging.getLogger("app.device_location") + + +@router.post("/{device_id}/reports", response_model=DeviceLocationReportResponse) +def create_device_location_report( + device_id: str, + payload: DeviceLocationReportRequest, + request: Request, + device_serial: str = Header(alias="X-Device-Serial", min_length=1), + db: Session = Depends(get_db), +) -> DeviceLocationReportResponse: + device_identity, row = report_device_location( + db=db, + device_id=device_id, + serial_number=device_serial, + payload=payload, + ) + + logger.info( + "device location reported", + extra={ + "event": "device_location_report", + "request_id": getattr(request.state, "request_id", None), + "device_id": device_id, + "child_id": device_identity.child_id, + "lat": float(row["lat"]), + "lng": float(row["lng"]), + }, + ) + + return DeviceLocationReportResponse( + child_id=int(row["child_id"]), + child_name=device_identity.child_name, + device_id=str(row["device_id"]), + coord_type=str(row["coord_type"]), + lat=float(row["lat"]), + lng=float(row["lng"]), + accuracy_m=row["accuracy_m"], + altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None, + speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None, + heading_deg=row["heading_deg"], + source=int(row["source"]), + battery_pct=row["battery_pct"], + device_time=row["device_time"], + server_time=row["server_time"], + updated_at=row["updated_at"], + ) diff --git a/mini-program/app/routers/devices.py b/mini-program/app/routers/devices.py new file mode 100644 index 0000000..4d40402 --- /dev/null +++ b/mini-program/app/routers/devices.py @@ -0,0 +1,253 @@ +import logging +from collections.abc import Mapping +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel +from sqlalchemy import text + +try: + from app.security import get_current_user_id + from app.service import get_db_session + from app.schemas.location import ( + DeviceLocationCurrentResponse, + DeviceLocationTrajectoryItem, + DeviceLocationTrajectoryResponse, + ) + from app.service.location import get_device_current_location, get_device_trajectory +except ModuleNotFoundError: + from security import get_current_user_id + from service import get_db_session + from schemas.location import ( + DeviceLocationCurrentResponse, + DeviceLocationTrajectoryItem, + DeviceLocationTrajectoryResponse, + ) + from service.location import get_device_current_location, get_device_trajectory + + +router = APIRouter(prefix="/devices", tags=["devices"]) +logger = logging.getLogger("app.devices") + + +class DeviceMessageItem(BaseModel): + id: int + conversation_id: int + role_key: str + is_user: bool + speaker: str + content: str + timestamp: float + created_at: datetime + + +class DeviceMessageListResponse(BaseModel): + items: list[DeviceMessageItem] + total: int + next_cursor: int | None = None + + +def _ensure_device_access(db, device_id: str, user_id: int) -> None: + row = ( + db.execute( + text( + """ + SELECT 1 + FROM device_bindings + WHERE device_id = :device_id + AND owner_user_id = :user_id + AND status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "user_id": user_id}, + ) + .mappings() + .first() + ) + if row is None: + raise HTTPException(status_code=404, detail="device not found") + + +def _row_to_message_item(row: Mapping) -> DeviceMessageItem: + is_user = bool(row["is_user"]) + return DeviceMessageItem( + id=int(row["id"]), + conversation_id=int(row["conversation_id"]), + role_key=str(row["role_key"]), + is_user=is_user, + speaker="user" if is_user else "assistant", + content=str(row["content"]), + timestamp=float(row["timestamp"]), + created_at=row["created_at"], + ) + + +def _row_to_current_location_response(row: Mapping) -> DeviceLocationCurrentResponse: + return DeviceLocationCurrentResponse( + child_id=int(row["child_id"]), + child_name=row.get("child_name"), + device_id=str(row["device_id"]), + coord_type=str(row["coord_type"]), + lat=float(row["lat"]), + lng=float(row["lng"]), + accuracy_m=row["accuracy_m"], + altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None, + speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None, + heading_deg=row["heading_deg"], + source=int(row["source"]), + battery_pct=row["battery_pct"], + device_time=row["device_time"], + server_time=row["server_time"], + updated_at=row["updated_at"], + ) + + +def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLocationTrajectoryItem: + return DeviceLocationTrajectoryItem( + id=int(row["id"]), + child_id=int(row["child_id"]), + child_name=child_name, + device_id=str(row["device_id"]), + coord_type=str(row["coord_type"]), + lat=float(row["lat"]), + lng=float(row["lng"]), + accuracy_m=row["accuracy_m"], + altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None, + speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None, + heading_deg=row["heading_deg"], + source=int(row["source"]), + battery_pct=row["battery_pct"], + device_time=row["device_time"], + server_time=row["server_time"], + created_at=row["created_at"], + ) + + +@router.get("/{device_id}/messages", response_model=DeviceMessageListResponse) +def list_device_messages( + device_id: str, + request: Request, + cursor: int | None = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), + db=Depends(get_db_session), +) -> DeviceMessageListResponse: + _ensure_device_access(db=db, device_id=device_id, user_id=current_user_id) + + params = {"device_id": device_id, "limit": limit + 1} + where = "ch.device_id = :device_id" + if cursor is not None: + where += " AND cm.id < :cursor" + params["cursor"] = cursor + + rows = ( + db.execute( + text( + f""" + SELECT + cm.id, + ch.id AS conversation_id, + ch.role_key, + cm.is_user, + cm.content, + cm.timestamp, + cm.created_at + FROM conversation_messages AS cm + JOIN conversation_histories AS ch + ON ch.id = cm.conversation_id + WHERE {where} + ORDER BY cm.id DESC + LIMIT :limit + """ + ), + params, + ) + .mappings() + .all() + ) + + has_more = len(rows) > limit + rows = rows[:limit] + next_cursor = int(rows[-1]["id"]) if has_more and rows else None + + logger.info( + "listed device ai messages", + extra={ + "event": "device_messages", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "returned_count": len(rows), + }, + ) + + return DeviceMessageListResponse( + items=[_row_to_message_item(row) for row in rows], + total=len(rows), + next_cursor=next_cursor, + ) + + +@router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse) +def get_current_device_location( + device_id: str, + request: Request, + current_user_id: int = Depends(get_current_user_id), + db=Depends(get_db_session), +) -> DeviceLocationCurrentResponse: + row = get_device_current_location(db=db, device_id=device_id, user_id=current_user_id) + + logger.info( + "device current location fetched", + extra={ + "event": "device_current_location", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "child_id": int(row["child_id"]), + }, + ) + return _row_to_current_location_response(row) + + +@router.get("/{device_id}/trajectory", response_model=DeviceLocationTrajectoryResponse) +def get_device_location_trajectory( + device_id: str, + request: Request, + start_at: datetime | None = Query(default=None), + end_at: datetime | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=1000), + current_user_id: int = Depends(get_current_user_id), + db=Depends(get_db_session), +) -> DeviceLocationTrajectoryResponse: + if start_at and end_at and start_at > end_at: + raise HTTPException(status_code=422, detail="start_at must be earlier than end_at") + + access, rows = get_device_trajectory( + db=db, + device_id=device_id, + user_id=current_user_id, + start_at=start_at, + end_at=end_at, + limit=limit, + ) + + logger.info( + "device trajectory fetched", + extra={ + "event": "device_trajectory", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "child_id": access.child_id, + "count": len(rows), + }, + ) + + return DeviceLocationTrajectoryResponse( + items=[_row_to_trajectory_item(row, child_name=access.child_name) for row in rows], + total=len(rows), + start_at=start_at, + end_at=end_at, + ) diff --git a/mini-program/app/routers/im.py b/mini-program/app/routers/im.py new file mode 100644 index 0000000..0b7b534 --- /dev/null +++ b/mini-program/app/routers/im.py @@ -0,0 +1,541 @@ +import json +import logging +from collections.abc import Mapping +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import text +from sqlalchemy.orm import Session + +try: + from app.db import get_db + from app.security import get_current_user_id + from app.schemas.im import ( + ChildConversationItem, + ChildConversationListResponse, + ChildConversationMessageItem, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + ParentChildMessageCreateRequest, + ) + from app.service.im import create_parent_child_message +except ModuleNotFoundError: + from db import get_db + from security import get_current_user_id + from schemas.im import ( + ChildConversationItem, + ChildConversationListResponse, + ChildConversationMessageItem, + ChildConversationMessageListResponse, + ConversationMessageCreateResponse, + ParentChildMessageCreateRequest, + ) + from service.im import create_parent_child_message + + +router = APIRouter(prefix="/children", tags=["im"]) +logger = logging.getLogger("app.im") + +PARENT_PARTICIPANT_TYPE = 1 +CHILD_PARTICIPANT_TYPE = 2 + +CHILD_PEER_CONVERSATION_TYPE = 1 +PARENT_CHILD_CONVERSATION_TYPE = 2 +SUPPORTED_CONVERSATION_TYPES = { + CHILD_PEER_CONVERSATION_TYPE, + PARENT_CHILD_CONVERSATION_TYPE, +} + +CONVERSATION_TYPE_NAMES = { + CHILD_PEER_CONVERSATION_TYPE: "child_peer", + PARENT_CHILD_CONVERSATION_TYPE: "parent_child", +} + +PARTICIPANT_TYPE_NAMES = { + PARENT_PARTICIPANT_TYPE: "parent", + CHILD_PARTICIPANT_TYPE: "child", +} + + +def _normalize_content_json(value: Any) -> dict[str, Any] | None: + if value is None: + return None + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + return None + return None + + +def _participant_type_name(participant_type: int) -> str: + return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}") + + +def _conversation_type_name(conversation_type: int) -> str: + return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}") + + +def _build_in_params(prefix: str, values: list[int]) -> tuple[str, dict[str, int]]: + placeholders: list[str] = [] + params: dict[str, int] = {} + for index, value in enumerate(values): + key = f"{prefix}_{index}" + placeholders.append(f":{key}") + params[key] = value + return ", ".join(placeholders), params + + +def _fetch_parent_names(db: Session, user_ids: set[int]) -> dict[int, str | None]: + if not user_ids: + return {} + + values = sorted(user_ids) + placeholders, params = _build_in_params("user_id", values) + rows = ( + db.execute( + text( + f""" + SELECT user_id, nickname + FROM parents + WHERE status = 1 + AND user_id IN ({placeholders}) + """ + ), + params, + ) + .mappings() + .all() + ) + return {int(row["user_id"]): row["nickname"] for row in rows} + + +def _fetch_child_names(db: Session, child_ids: set[int]) -> dict[int, str | None]: + if not child_ids: + return {} + + values = sorted(child_ids) + placeholders, params = _build_in_params("child_id", values) + rows = ( + db.execute( + text( + f""" + SELECT child_id, child_name + FROM children + WHERE status = 1 + AND child_id IN ({placeholders}) + """ + ), + params, + ) + .mappings() + .all() + ) + return {int(row["child_id"]): row["child_name"] for row in rows} + + +def _assert_child_access(db: Session, child_id: int, user_id: int) -> None: + child_row = ( + db.execute( + text( + """ + SELECT child_id + FROM children + WHERE child_id = :child_id + AND status = 1 + LIMIT 1 + """ + ), + {"child_id": child_id}, + ) + .mappings() + .first() + ) + if not child_row: + raise HTTPException(status_code=404, detail="child not found") + + has_access = ( + db.execute( + text( + """ + SELECT 1 + FROM parent_child_relations + WHERE user_id = :user_id + AND child_id = :child_id + AND status = 1 + LIMIT 1 + """ + ), + {"user_id": user_id, "child_id": child_id}, + ).scalar_one_or_none() + is not None + ) + if not has_access: + raise HTTPException(status_code=403, detail="no permission to access this child") + + +def _get_conversation_for_child( + db: Session, + *, + conversation_id: int, + child_id: int, +) -> Mapping[str, Any]: + child_id_str = str(child_id) + row = ( + db.execute( + text( + """ + SELECT + id, + conversation_type, + participant_a_type, + participant_a_id, + participant_b_type, + participant_b_id, + status + FROM im_conversations + WHERE id = :conversation_id + LIMIT 1 + """ + ), + {"conversation_id": conversation_id}, + ) + .mappings() + .first() + ) + if not row: + raise HTTPException(status_code=404, detail="conversation not found") + + conversation_type = int(row["conversation_type"]) + if conversation_type not in SUPPORTED_CONVERSATION_TYPES or int(row["status"]) != 1: + raise HTTPException(status_code=404, detail="conversation not found") + + is_child_participant = ( + ( + int(row["participant_a_type"]) == CHILD_PARTICIPANT_TYPE + and row["participant_a_id"] == child_id_str + ) + or ( + int(row["participant_b_type"]) == CHILD_PARTICIPANT_TYPE + and row["participant_b_id"] == child_id_str + ) + ) + if not is_child_participant: + raise HTTPException(status_code=404, detail="conversation not found") + + return row + + +def _row_to_conversation_item( + row: Mapping[str, Any], + *, + child_id: int, + parent_names: Mapping[int, str | None], + child_names: Mapping[int, str | None], +) -> ChildConversationItem: + child_id_str = str(child_id) + participant_a_type = int(row["participant_a_type"]) + participant_b_type = int(row["participant_b_type"]) + participant_a_id = str(row["participant_a_id"]) + participant_b_id = str(row["participant_b_id"]) + + if participant_a_type == CHILD_PARTICIPANT_TYPE and participant_a_id == child_id_str: + peer_type = participant_b_type + peer_id = participant_b_id + else: + peer_type = participant_a_type + peer_id = participant_a_id + + peer_name: str | None = None + if peer_type == PARENT_PARTICIPANT_TYPE and peer_id.isdigit(): + peer_name = parent_names.get(int(peer_id)) + elif peer_type == CHILD_PARTICIPANT_TYPE and peer_id.isdigit(): + peer_name = child_names.get(int(peer_id)) + + return ChildConversationItem( + conversation_id=int(row["id"]), + conversation_type=int(row["conversation_type"]), + conversation_type_name=_conversation_type_name(int(row["conversation_type"])), + peer_type=_participant_type_name(peer_type), + peer_id=peer_id, + peer_name=peer_name, + last_message_preview=row["last_message_preview"], + last_message_at=row["last_message_at"], + message_count=int(row["message_count"]), + ) + + +def _row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem: + return ChildConversationMessageItem( + id=int(row["id"]), + conversation_id=int(row["conversation_id"]), + seq=int(row["seq"]), + sender_type=_participant_type_name(int(row["sender_type"])), + sender_id=str(row["sender_id"]), + receiver_type=_participant_type_name(int(row["receiver_type"])), + receiver_id=str(row["receiver_id"]), + content_type=int(row["content_type"]), + content_text=row["content_text"], + content_json=_normalize_content_json(row["content_json"]), + media_file_key=row["media_file_key"], + media_duration_ms=row["media_duration_ms"], + media_mime_type=row.get("media_mime_type"), + media_size_bytes=row.get("media_size_bytes"), + media_transcript_text=row.get("media_transcript_text"), + client_msg_id=row["client_msg_id"], + sender_name_snapshot=row["sender_name_snapshot"], + sender_avatar_snapshot=row.get("sender_avatar_snapshot"), + receiver_name_snapshot=row["receiver_name_snapshot"], + receiver_avatar_snapshot=row.get("receiver_avatar_snapshot"), + ext_json=_normalize_content_json(row.get("ext_json")), + created_at=row["created_at"], + ) + + +@router.get("/{child_id}/conversations", response_model=ChildConversationListResponse) +def list_child_conversations( + child_id: int, + request: Request, + conversation_type: int | None = Query(default=None), + cursor: int | None = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), + db: Session = Depends(get_db), +) -> ChildConversationListResponse: + if conversation_type is not None and conversation_type not in SUPPORTED_CONVERSATION_TYPES: + raise HTTPException(status_code=422, detail="unsupported conversation_type") + + _assert_child_access(db=db, child_id=child_id, user_id=current_user_id) + + params: dict[str, Any] = { + "child_id_str": str(child_id), + "child_participant_type": CHILD_PARTICIPANT_TYPE, + "child_peer_conversation_type": CHILD_PEER_CONVERSATION_TYPE, + "parent_child_conversation_type": PARENT_CHILD_CONVERSATION_TYPE, + "fetch_limit": limit + 1, + } + where = """ + status = 1 + AND conversation_type IN (:child_peer_conversation_type, :parent_child_conversation_type) + AND ( + (participant_a_type = :child_participant_type AND participant_a_id = :child_id_str) + OR (participant_b_type = :child_participant_type AND participant_b_id = :child_id_str) + ) + """ + if conversation_type is not None: + where += " AND conversation_type = :conversation_type" + params["conversation_type"] = conversation_type + if cursor is not None: + where += " AND id < :cursor" + params["cursor"] = cursor + + rows = ( + db.execute( + text( + f""" + SELECT + id, + conversation_type, + participant_a_type, + participant_a_id, + participant_b_type, + participant_b_id, + last_message_preview, + last_message_at, + message_count, + created_at + FROM im_conversations + WHERE {where} + ORDER BY COALESCE(last_message_at, created_at) DESC, id DESC + LIMIT :fetch_limit + """ + ), + params, + ) + .mappings() + .all() + ) + + has_more = len(rows) > limit + rows = rows[:limit] + next_cursor = int(rows[-1]["id"]) if has_more and rows else None + + peer_parent_ids: set[int] = set() + peer_child_ids: set[int] = set() + child_id_str = str(child_id) + for row in rows: + participant_a_type = int(row["participant_a_type"]) + participant_b_type = int(row["participant_b_type"]) + participant_a_id = str(row["participant_a_id"]) + participant_b_id = str(row["participant_b_id"]) + if participant_a_type == CHILD_PARTICIPANT_TYPE and participant_a_id == child_id_str: + peer_type = participant_b_type + peer_id = participant_b_id + else: + peer_type = participant_a_type + peer_id = participant_a_id + + if peer_id.isdigit(): + if peer_type == PARENT_PARTICIPANT_TYPE: + peer_parent_ids.add(int(peer_id)) + elif peer_type == CHILD_PARTICIPANT_TYPE: + peer_child_ids.add(int(peer_id)) + + parent_names = _fetch_parent_names(db, peer_parent_ids) + child_names = _fetch_child_names(db, peer_child_ids) + + items = [ + _row_to_conversation_item( + row, + child_id=child_id, + parent_names=parent_names, + child_names=child_names, + ) + for row in rows + ] + + logger.info( + "child conversations listed", + extra={ + "event": "child_conversation_list", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "child_id": child_id, + "conversation_type": conversation_type, + "count": len(items), + }, + ) + + return ChildConversationListResponse( + items=items, + total=len(items), + next_cursor=next_cursor, + ) + + +@router.post( + "/{child_id}/messages", + response_model=ConversationMessageCreateResponse, + status_code=status.HTTP_201_CREATED, +) +def create_child_message_for_parent( + child_id: int, + payload: ParentChildMessageCreateRequest, + request: Request, + response: Response, + current_user_id: int = Depends(get_current_user_id), + db: Session = Depends(get_db), +) -> ConversationMessageCreateResponse: + result = create_parent_child_message( + db=db, + parent_user_id=current_user_id, + child_id=child_id, + payload=payload, + ) + if result.idempotent: + response.status_code = status.HTTP_200_OK + + logger.info( + "parent child message created", + extra={ + "event": "parent_child_message_create", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "child_id": child_id, + "conversation_id": result.conversation_id, + "idempotent": result.idempotent, + }, + ) + + return ConversationMessageCreateResponse( + idempotent=result.idempotent, + conversation_id=result.conversation_id, + conversation_type=result.conversation_type, + conversation_type_name=result.conversation_type_name, + message=result.message, + ) + + +@router.get( + "/{child_id}/conversations/{conversation_id}/messages", + response_model=ChildConversationMessageListResponse, +) +def list_child_conversation_messages( + child_id: int, + conversation_id: int, + request: Request, + cursor_seq: int | None = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), + db: Session = Depends(get_db), +) -> ChildConversationMessageListResponse: + _assert_child_access(db=db, child_id=child_id, user_id=current_user_id) + _get_conversation_for_child(db=db, conversation_id=conversation_id, child_id=child_id) + + sql = """ + SELECT + id, + conversation_id, + seq, + sender_type, + sender_id, + receiver_type, + receiver_id, + content_type, + content_text, + content_json, + media_file_key, + media_duration_ms, + media_mime_type, + media_size_bytes, + media_transcript_text, + client_msg_id, + sender_name_snapshot, + sender_avatar_snapshot, + receiver_name_snapshot, + receiver_avatar_snapshot, + ext_json, + created_at + FROM im_messages + WHERE conversation_id = :conversation_id + AND deleted_at IS NULL + """ + params: dict[str, Any] = { + "conversation_id": conversation_id, + "fetch_limit": limit + 1, + } + if cursor_seq is not None: + sql += " AND seq < :cursor_seq" + params["cursor_seq"] = cursor_seq + sql += " ORDER BY seq DESC LIMIT :fetch_limit" + + rows = db.execute(text(sql), params).mappings().all() + has_more = len(rows) > limit + rows = rows[:limit] + rows.reverse() + items = [_row_to_message_item(row) for row in rows] + next_cursor_seq = items[0].seq if has_more and items else None + + logger.info( + "child conversation messages listed", + extra={ + "event": "child_conversation_message_list", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "child_id": child_id, + "conversation_id": conversation_id, + "count": len(items), + "has_more": has_more, + }, + ) + + return ChildConversationMessageListResponse( + conversation_id=conversation_id, + has_more=has_more, + next_cursor_seq=next_cursor_seq, + items=items, + ) diff --git a/mini-program/app/routers/messages.py b/mini-program/app/routers/messages.py deleted file mode 100644 index 4b23553..0000000 --- a/mini-program/app/routers/messages.py +++ /dev/null @@ -1,433 +0,0 @@ -import json -import logging -from collections.abc import Mapping -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status -from sqlalchemy import text -from sqlalchemy.orm import Session - -try: - # For module mode: `uvicorn app.main:app` - from app.db import get_db - from app.security import get_current_user_id - from app.schemas.message import ( - MessageCreateRequest, - MessageCreateResponse, - MessageItem, - MessageListResponse, - ) -except ModuleNotFoundError: - # For script mode: `python app/main.py` or VS Code "Run Python File" - from db import get_db - from security import get_current_user_id - from schemas.message import ( - MessageCreateRequest, - MessageCreateResponse, - MessageItem, - MessageListResponse, - ) - - -router = APIRouter(prefix="/messages", tags=["messages"]) -logger = logging.getLogger("app.messages") - - -def _build_preview(content_type: int, content_text: str | None) -> str: - if content_type == 1: - preview = f"user: {(content_text or '').strip()}" - elif content_type == 2: - preview = "user: [audio]" - elif content_type == 3: - preview = "user: [image]" - else: - preview = "user: [json]" - return preview[:255] - - -def _normalize_content_json(value: Any) -> dict[str, Any] | None: - if value is None: - return None - if isinstance(value, dict): - return value - if isinstance(value, str): - try: - parsed = json.loads(value) - if isinstance(parsed, dict): - return parsed - except json.JSONDecodeError: - return None - return None - - -def _row_to_message_item(row: Mapping[str, Any]) -> MessageItem: - return MessageItem( - id=int(row["id"]), - conversation_id=int(row["conversation_id"]), - seq=int(row["seq"]), - sender_user_id=row["sender_user_id"], - role=int(row["role"]), - content_type=int(row["content_type"]), - content_text=row["content_text"], - content_json=_normalize_content_json(row["content_json"]), - media_file_key=row["media_file_key"], - media_duration_ms=row["media_duration_ms"], - client_msg_id=row["client_msg_id"], - created_at=row["created_at"], - ) - - -def _get_existing_message( - db: Session, conversation_id: int, client_msg_id: str -) -> Mapping[str, Any] | None: - return ( - db.execute( - text( - """ - SELECT - id, - conversation_id, - seq, - sender_user_id, - role, - content_type, - content_text, - content_json, - media_file_key, - media_duration_ms, - client_msg_id, - created_at - FROM chat_message - WHERE conversation_id = :conversation_id - AND client_msg_id = :client_msg_id - LIMIT 1 - """ - ), - {"conversation_id": conversation_id, "client_msg_id": client_msg_id}, - ) - .mappings() - .first() - ) - - -def _assert_conversation_access( - db: Session, - conversation_id: int, - current_user_id: int, -) -> None: - conversation_row = ( - db.execute( - text( - """ - SELECT id, user_low_id, user_high_id - FROM chat_conversation - WHERE id = :conversation_id - LIMIT 1 - """ - ), - {"conversation_id": conversation_id}, - ) - .mappings() - .first() - ) - if not conversation_row: - raise HTTPException(status_code=404, detail="conversation not found") - - if current_user_id not in ( - int(conversation_row["user_low_id"]), - int(conversation_row["user_high_id"]), - ): - raise HTTPException(status_code=403, detail="no permission for this conversation") - - -@router.post("", response_model=MessageCreateResponse, status_code=status.HTTP_201_CREATED) -def create_message( - payload: MessageCreateRequest, - request: Request, - response: Response, - current_user_id: int = Depends(get_current_user_id), - db: Session = Depends(get_db), -) -> MessageCreateResponse: - sender_user_id = current_user_id - peer_user_id = payload.peer_user_id - if sender_user_id == peer_user_id: - raise HTTPException(status_code=422, detail="peer_user_id cannot be same as current user") - - user_low_id = min(sender_user_id, peer_user_id) - user_high_id = max(sender_user_id, peer_user_id) - - with db.begin(): - user_count = db.execute( - text( - """ - SELECT COUNT(*) AS cnt - FROM chat_user - WHERE id IN (:sender_user_id, :peer_user_id) - AND status = 1 - """ - ), - {"sender_user_id": sender_user_id, "peer_user_id": peer_user_id}, - ).scalar_one() - - if int(user_count) != 2: - raise HTTPException(status_code=404, detail="sender or peer user not found") - - db.execute( - text( - """ - INSERT INTO chat_conversation ( - user_low_id, - user_high_id, - status, - last_seq, - message_count, - created_at, - updated_at - ) - VALUES ( - :user_low_id, - :user_high_id, - 1, - 0, - 0, - CURRENT_TIMESTAMP(3), - CURRENT_TIMESTAMP(3) - ) - ON DUPLICATE KEY UPDATE - id = LAST_INSERT_ID(id), - updated_at = CURRENT_TIMESTAMP(3) - """ - ), - {"user_low_id": user_low_id, "user_high_id": user_high_id}, - ) - - conversation_id = int(db.execute(text("SELECT LAST_INSERT_ID()")).scalar_one()) - - conversation = ( - db.execute( - text( - """ - SELECT id, last_seq, status - FROM chat_conversation - WHERE id = :conversation_id - FOR UPDATE - """ - ), - {"conversation_id": conversation_id}, - ) - .mappings() - .first() - ) - if not conversation: - raise HTTPException(status_code=500, detail="failed to load conversation") - - if int(conversation["status"]) != 1: - raise HTTPException(status_code=409, detail="conversation is not active") - - existing = _get_existing_message(db, conversation_id, payload.client_msg_id) - if existing: - logger.info( - "message idempotent hit", - extra={ - "event": "message_create", - "request_id": getattr(request.state, "request_id", None), - "user_id": sender_user_id, - "conversation_id": conversation_id, - "message_id": int(existing["id"]), - "seq": int(existing["seq"]), - "client_msg_id": payload.client_msg_id, - "idempotent": True, - }, - ) - response.status_code = status.HTTP_200_OK - return MessageCreateResponse( - idempotent=True, - message=_row_to_message_item(existing), - ) - - next_seq = int(conversation["last_seq"]) + 1 - preview = _build_preview(payload.content_type, payload.content_text) - - insert_result = db.execute( - text( - """ - INSERT INTO chat_message ( - conversation_id, - seq, - sender_user_id, - role, - content_type, - content_text, - content_json, - media_file_key, - media_duration_ms, - client_msg_id, - created_at - ) - VALUES ( - :conversation_id, - :seq, - :sender_user_id, - 1, - :content_type, - :content_text, - :content_json, - :media_file_key, - :media_duration_ms, - :client_msg_id, - CURRENT_TIMESTAMP(3) - ) - """ - ), - { - "conversation_id": conversation_id, - "seq": next_seq, - "sender_user_id": sender_user_id, - "content_type": payload.content_type, - "content_text": payload.content_text, - "content_json": json.dumps(payload.content_json, ensure_ascii=False) - if payload.content_json is not None - else None, - "media_file_key": payload.media_file_key, - "media_duration_ms": payload.media_duration_ms, - "client_msg_id": payload.client_msg_id, - }, - ) - - db.execute( - text( - """ - UPDATE chat_conversation - SET - last_seq = :last_seq, - message_count = message_count + 1, - last_message_preview = :last_message_preview, - last_message_at = CURRENT_TIMESTAMP(3), - updated_at = CURRENT_TIMESTAMP(3) - WHERE id = :conversation_id - """ - ), - { - "conversation_id": conversation_id, - "last_seq": next_seq, - "last_message_preview": preview, - }, - ) - - created = ( - db.execute( - text( - """ - SELECT - id, - conversation_id, - seq, - sender_user_id, - role, - content_type, - content_text, - content_json, - media_file_key, - media_duration_ms, - client_msg_id, - created_at - FROM chat_message - WHERE id = :message_id - LIMIT 1 - """ - ), - {"message_id": insert_result.lastrowid}, - ) - .mappings() - .first() - ) - if not created: - raise HTTPException(status_code=500, detail="failed to load created message") - - logger.info( - "message created", - extra={ - "event": "message_create", - "request_id": getattr(request.state, "request_id", None), - "user_id": sender_user_id, - "conversation_id": conversation_id, - "message_id": int(created["id"]), - "seq": int(created["seq"]), - "client_msg_id": payload.client_msg_id, - "idempotent": False, - }, - ) - return MessageCreateResponse( - idempotent=False, - message=_row_to_message_item(created), - ) - - -@router.get("", response_model=MessageListResponse) -def list_messages( - request: Request, - conversation_id: int = Query(gt=0), - cursor_seq: int | None = Query(default=None, ge=1), - limit: int = Query(default=20, ge=1, le=100), - current_user_id: int = Depends(get_current_user_id), - db: Session = Depends(get_db), -) -> MessageListResponse: - _assert_conversation_access( - db=db, - conversation_id=conversation_id, - current_user_id=current_user_id, - ) - - sql = """ - SELECT - id, - conversation_id, - seq, - sender_user_id, - role, - content_type, - content_text, - content_json, - media_file_key, - media_duration_ms, - client_msg_id, - created_at - FROM chat_message - WHERE conversation_id = :conversation_id - AND deleted_at IS NULL - """ - params: dict[str, Any] = { - "conversation_id": conversation_id, - "fetch_limit": limit + 1, - } - if cursor_seq is not None: - sql += " AND seq < :cursor_seq" - params["cursor_seq"] = cursor_seq - sql += " ORDER BY seq DESC LIMIT :fetch_limit" - - rows = db.execute(text(sql), params).mappings().all() - has_more = len(rows) > limit - rows = rows[:limit] - rows.reverse() - items = [_row_to_message_item(row) for row in rows] - - next_cursor_seq = items[0].seq if has_more and items else None - logger.info( - "messages listed", - extra={ - "event": "message_list", - "request_id": getattr(request.state, "request_id", None), - "user_id": current_user_id, - "conversation_id": conversation_id, - "cursor_seq": cursor_seq, - "limit": limit, - "count": len(items), - "has_more": has_more, - }, - ) - return MessageListResponse( - conversation_id=conversation_id, - has_more=has_more, - next_cursor_seq=next_cursor_seq, - items=items, - ) diff --git a/mini-program/app/routers/parents.py b/mini-program/app/routers/parents.py index bb2c230..1f4082f 100644 --- a/mini-program/app/routers/parents.py +++ b/mini-program/app/routers/parents.py @@ -1,14 +1,18 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from pydantic import BaseModel try: from app.service.parent import ParentService from app.service import get_db_session + from app.service.avatar_storage import AvatarStorageError + from app.security import get_current_user_id except ModuleNotFoundError: from service.parent import ParentService from service import get_db_session + from service.avatar_storage import AvatarStorageError + from security import get_current_user_id router = APIRouter(prefix="/parents", tags=["parents"]) @@ -38,6 +42,11 @@ class ParentUpdateRequest(BaseModel): phone: str | None = None +class AvatarDownloadResponse(BaseModel): + avatar_url: str + expires_in: int | None = None + + @router.post("", response_model=ParentResponse, status_code=status.HTTP_201_CREATED) def create_parent(payload: ParentCreateRequest, request: Request, db=Depends(get_db_session)) -> ParentResponse: service = ParentService(db) @@ -45,6 +54,43 @@ def create_parent(payload: ParentCreateRequest, request: Request, db=Depends(get return ParentResponse(**parent) +@router.post("/me/avatar", response_model=ParentResponse) +def upload_my_avatar( + request: Request, + file: UploadFile = File(...), + current_user_id: int = Depends(get_current_user_id), + db=Depends(get_db_session), +) -> ParentResponse: + del request + service = ParentService(db) + try: + content = file.file.read() + parent = service.upload_avatar( + user_id=current_user_id, + filename=file.filename, + content_type=file.content_type, + content=content, + ) + except AvatarStorageError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + finally: + file.file.close() + + if not parent: + raise HTTPException(status_code=404, detail="parent not found") + return ParentResponse(**parent) + + +@router.get("/{user_id}/avatar", response_model=AvatarDownloadResponse) +def get_parent_avatar(user_id: int, request: Request, db=Depends(get_db_session)) -> AvatarDownloadResponse: + del request + service = ParentService(db) + avatar = service.get_avatar_download(user_id) + if not avatar: + raise HTTPException(status_code=404, detail="avatar not found") + return AvatarDownloadResponse(**avatar) + + @router.get("/{user_id}", response_model=ParentResponse) def get_parent(user_id: int, request: Request, db=Depends(get_db_session)) -> ParentResponse: service = ParentService(db) @@ -60,4 +106,4 @@ def update_parent(user_id: int, payload: ParentUpdateRequest, request: Request, parent = service.update(user_id, payload.nickname, payload.avatar_url, payload.phone) if not parent: raise HTTPException(status_code=404, detail="parent not found") - return ParentResponse(**parent) \ No newline at end of file + return ParentResponse(**parent) diff --git a/mini-program/app/routers/wechat_auth.py b/mini-program/app/routers/wechat_auth.py index 4b6431a..8a2f4eb 100644 --- a/mini-program/app/routers/wechat_auth.py +++ b/mini-program/app/routers/wechat_auth.py @@ -1,17 +1,23 @@ import logging -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Request, status -from pydantic import BaseModel -from sqlalchemy import text +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field from sqlalchemy.orm import Session try: from app.db import get_db from app.security import create_access_token + from app.service.parent import ParentService + from app.service.wechat_login import ( + WechatAuthError, + WechatAuthService, + get_wechat_auth_service, + ) except ModuleNotFoundError: from db import get_db from security import create_access_token + from service.parent import ParentService + from service.wechat_login import WechatAuthError, WechatAuthService, get_wechat_auth_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -19,15 +25,14 @@ logger = logging.getLogger("app.auth") class LoginRequest(BaseModel): - username: Optional[str] = None - password: Optional[str] = None - code: Optional[str] = None - nickname: Optional[str] = None - avatar_url: Optional[str] = None + code: str = Field(min_length=1, max_length=191) + nickname: str | None = Field(default=None, max_length=64) + avatar_url: str | None = Field(default=None, max_length=255) class LoginResponse(BaseModel): access_token: str + token_type: str = "bearer" expires_in: int user_id: int @@ -37,51 +42,38 @@ def login( payload: LoginRequest, request: Request, db: Session = Depends(get_db), + wechat_auth_service: WechatAuthService = Depends(get_wechat_auth_service), ) -> LoginResponse: - logger.info(f"/auth/login called with code={payload.code[:20] if payload.code else None}...") - identifier = payload.code or payload.username - if not identifier: - raise HTTPException(status_code=400, detail="username or code is required") + del request + logger.info("/auth/login called") - with db.begin(): - row = ( - db.execute( - text("SELECT user_id FROM parents WHERE openid = :openid"), - {"openid": identifier}, - ).mappings().first() + try: + wechat_session = wechat_auth_service.exchange_code(payload.code) + except WechatAuthError as exc: + logger.warning( + "wechat login failed", + extra={ + "event": "wechat_login_failed", + "status_code": exc.status_code, + "errcode": exc.errcode, + }, ) + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc - if row: - user_id = int(row["user_id"]) - logger.info(f"Existing user found: user_id={user_id}") - if payload.nickname or payload.avatar_url: - db.execute( - text( - """ - UPDATE parents - SET nickname = COALESCE(:nickname, nickname), - avatar_url = COALESCE(:avatar_url, avatar_url) - WHERE user_id = :user_id - """ - ), - {"user_id": user_id, "nickname": payload.nickname, "avatar_url": payload.avatar_url}, - ) - else: - logger.info("Creating new user...") - db.execute( - text( - "INSERT INTO parents (openid, nickname, avatar_url, status) VALUES (:openid, :nickname, :avatar_url, 1)" - ), - {"openid": identifier, "nickname": payload.nickname, "avatar_url": payload.avatar_url}, - ) - user_id = int(db.execute(text("SELECT last_insert_rowid()")).scalar_one()) - logger.info(f"New user created: user_id={user_id}") + parent_service = ParentService(db) + parent = parent_service.create( + openid=wechat_session.openid, + unionid=wechat_session.unionid, + nickname=payload.nickname, + avatar_url=payload.avatar_url, + ) + user_id = int(parent["user_id"]) access_token, expires_in = create_access_token(user_id=user_id) - logger.info(f"Login succeeded: user_id={user_id}, expires_in={expires_in}") + logger.info("wechat login succeeded", extra={"event": "wechat_login_succeeded", "user_id": user_id}) return LoginResponse(access_token=access_token, expires_in=expires_in, user_id=user_id) @router.post("/logout") def logout(request: Request): - return {"message": "logged out"} \ No newline at end of file + return {"message": "logged out"} diff --git a/mini-program/app/schemas/im.py b/mini-program/app/schemas/im.py new file mode 100644 index 0000000..cdab21d --- /dev/null +++ b/mini-program/app/schemas/im.py @@ -0,0 +1,111 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, model_validator + + +class ChildConversationItem(BaseModel): + conversation_id: int + conversation_type: int + conversation_type_name: str + peer_type: str + peer_id: str + peer_name: str | None = None + last_message_preview: str | None = None + last_message_at: datetime | None = None + message_count: int + + +class ChildConversationListResponse(BaseModel): + items: list[ChildConversationItem] + total: int + next_cursor: int | None = None + + +class ChildConversationMessageItem(BaseModel): + id: int + conversation_id: int + seq: int + sender_type: str + sender_id: str + receiver_type: str + receiver_id: str + content_type: int + content_text: str | None = None + content_json: dict[str, Any] | None = None + media_file_key: str | None = None + media_duration_ms: int | None = None + media_mime_type: str | None = None + media_size_bytes: int | None = None + media_transcript_text: str | None = None + client_msg_id: str | None = None + sender_name_snapshot: str | None = None + sender_avatar_snapshot: str | None = None + receiver_name_snapshot: str | None = None + receiver_avatar_snapshot: str | None = None + ext_json: dict[str, Any] | None = None + created_at: datetime + + +class ChildConversationMessageListResponse(BaseModel): + conversation_id: int + has_more: bool + next_cursor_seq: int | None = None + items: list[ChildConversationMessageItem] + + +class BaseConversationMessageCreateRequest(BaseModel): + content_type: int = Field(ge=1, le=4) + content_text: str | None = None + content_json: dict[str, Any] | None = None + media_file_key: str | None = None + media_duration_ms: int | None = Field(default=None, ge=0) + media_mime_type: str | None = None + media_size_bytes: int | None = Field(default=None, ge=0) + media_transcript_text: str | None = None + client_msg_id: str = Field(min_length=1, max_length=64) + ext_json: dict[str, Any] | None = None + + @model_validator(mode="after") + def validate_message_payload(self) -> "BaseConversationMessageCreateRequest": + if self.content_type == 1 and not self.content_text: + raise ValueError("content_text is required when content_type=1") + if self.content_type == 2 and not self.media_file_key: + raise ValueError("media_file_key is required when content_type=2") + if self.content_type == 3 and not self.media_file_key: + raise ValueError("media_file_key is required when content_type=3") + if self.content_type == 4 and self.content_json is None: + raise ValueError("content_json is required when content_type=4") + return self + + +class ParentChildMessageCreateRequest(BaseConversationMessageCreateRequest): + pass + + +class DeviceMessageCreateRequest(BaseConversationMessageCreateRequest): + conversation_type: int = Field(ge=1, le=2) + peer_child_id: int | None = Field(default=None, ge=1) + parent_user_id: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def validate_target(self) -> "DeviceMessageCreateRequest": + if self.conversation_type == 1: + if self.peer_child_id is None: + raise ValueError("peer_child_id is required when conversation_type=1") + if self.parent_user_id is not None: + raise ValueError("parent_user_id must be empty when conversation_type=1") + if self.conversation_type == 2: + if self.parent_user_id is None: + raise ValueError("parent_user_id is required when conversation_type=2") + if self.peer_child_id is not None: + raise ValueError("peer_child_id must be empty when conversation_type=2") + return self + + +class ConversationMessageCreateResponse(BaseModel): + idempotent: bool + conversation_id: int + conversation_type: int + conversation_type_name: str + message: ChildConversationMessageItem diff --git a/mini-program/app/schemas/location.py b/mini-program/app/schemas/location.py new file mode 100644 index 0000000..bbb412c --- /dev/null +++ b/mini-program/app/schemas/location.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DeviceLocationReportRequest(BaseModel): + coord_type: str = Field(default="gcj02", min_length=1, max_length=16) + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + accuracy_m: int | None = Field(default=None, ge=0) + altitude_m: float | None = None + speed_mps: float | None = None + heading_deg: int | None = Field(default=None, ge=0, le=360) + source: int = Field(ge=0) + battery_pct: int | None = Field(default=None, ge=0, le=100) + device_time: datetime + + +class DeviceLocationPoint(BaseModel): + child_id: int + child_name: str | None = None + device_id: str + coord_type: str + lat: float + lng: float + accuracy_m: int | None = None + altitude_m: float | None = None + speed_mps: float | None = None + heading_deg: int | None = None + source: int + battery_pct: int | None = None + device_time: datetime + server_time: datetime + + +class DeviceLocationCurrentResponse(DeviceLocationPoint): + updated_at: datetime + + +class DeviceLocationReportResponse(DeviceLocationCurrentResponse): + pass + + +class DeviceLocationTrajectoryItem(DeviceLocationPoint): + id: int + created_at: datetime + + +class DeviceLocationTrajectoryResponse(BaseModel): + items: list[DeviceLocationTrajectoryItem] + total: int + start_at: datetime | None = None + end_at: datetime | None = None diff --git a/mini-program/app/schemas/message.py b/mini-program/app/schemas/message.py deleted file mode 100644 index c76e7d3..0000000 --- a/mini-program/app/schemas/message.py +++ /dev/null @@ -1,57 +0,0 @@ -from datetime import datetime -from typing import Any - -from pydantic import BaseModel, Field, model_validator - - -class MessageCreateRequest(BaseModel): - peer_user_id: int = Field(gt=0) - client_msg_id: str = Field(min_length=1, max_length=64) - content_type: int = Field(description="1-text 2-audio 3-image 4-json") - content_text: str | None = Field(default=None) - content_json: dict[str, Any] | None = None - media_file_key: str | None = Field(default=None, max_length=255) - media_duration_ms: int | None = Field(default=None, ge=0) - - @model_validator(mode="after") - def validate_message_fields(self) -> "MessageCreateRequest": - if self.content_type not in {1, 2, 3, 4}: - raise ValueError("content_type must be one of 1, 2, 3, 4") - - if self.content_type == 1 and not (self.content_text and self.content_text.strip()): - raise ValueError("content_text is required when content_type is 1") - - if self.content_type in {2, 3} and not self.media_file_key: - raise ValueError("media_file_key is required when content_type is 2 or 3") - - if self.content_type == 4 and self.content_json is None: - raise ValueError("content_json is required when content_type is 4") - - return self - - -class MessageItem(BaseModel): - id: int - conversation_id: int - seq: int - sender_user_id: int | None - role: int - content_type: int - content_text: str | None - content_json: dict[str, Any] | None - media_file_key: str | None - media_duration_ms: int | None - client_msg_id: str | None - created_at: datetime - - -class MessageCreateResponse(BaseModel): - idempotent: bool - message: MessageItem - - -class MessageListResponse(BaseModel): - conversation_id: int - has_more: bool - next_cursor_seq: int | None - items: list[MessageItem] diff --git a/mini-program/app/service/avatar_storage.py b/mini-program/app/service/avatar_storage.py new file mode 100644 index 0000000..5d4ab4e --- /dev/null +++ b/mini-program/app/service/avatar_storage.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +try: + from qcloud_cos import CosConfig, CosS3Client +except ModuleNotFoundError: # pragma: no cover - exercised in runtime env + CosConfig = None + CosS3Client = None + +from app.settings import settings + + +_CONTENT_TYPE_TO_EXT = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", +} +_EXTENSION_ALIASES = { + ".jpg": "jpg", + ".jpeg": "jpg", + ".png": "png", + ".webp": "webp", +} +_EXT_TO_CONTENT_TYPE = { + "jpg": "image/jpeg", + "png": "image/png", + "webp": "image/webp", +} + + +class AvatarStorageError(Exception): + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +@dataclass(frozen=True) +class StoredAvatar: + file_key: str + + +class AvatarStorageService: + def __init__(self) -> None: + self._client = None + + def upload_avatar( + self, + *, + user_id: int, + filename: str | None, + content_type: str | None, + content: bytes, + ) -> StoredAvatar: + self._assert_ready() + normalized_ext = self._normalize_extension(filename=filename, content_type=content_type) + if not content: + raise AvatarStorageError("avatar file is empty") + if len(content) > settings.cos_avatar_max_bytes: + raise AvatarStorageError("avatar file too large", status_code=413) + + key = self._build_key(user_id=user_id, extension=normalized_ext) + self._get_client().put_object( + Bucket=settings.cos_bucket_ava, + Body=content, + Key=key, + ContentType=_EXT_TO_CONTENT_TYPE[normalized_ext], + EnableMD5=False, + ) + return StoredAvatar(file_key=key) + + def get_avatar_url(self, file_key: str) -> str: + self._assert_ready() + if not file_key: + raise AvatarStorageError("avatar file key is required", status_code=500) + return self._get_client().get_presigned_url( + Bucket=settings.cos_bucket_ava, + Key=file_key, + Method="GET", + Expired=settings.cos_avatar_url_expire_seconds, + ) + + def delete_avatar(self, file_key: str) -> None: + self._assert_ready() + if not file_key: + return + self._get_client().delete_object(Bucket=settings.cos_bucket_ava, Key=file_key) + + def _assert_ready(self) -> None: + if CosConfig is None or CosS3Client is None: + raise AvatarStorageError("COS SDK is not installed", status_code=500) + + required_pairs = { + "COS_SECRET_ID": settings.cos_secret_id, + "COS_SECRET_KEY": settings.cos_secret_key, + "COS_REGION": settings.cos_region, + "COS_BUCKET_AVA": settings.cos_bucket_ava, + } + missing = [key for key, value in required_pairs.items() if not value] + if missing: + raise AvatarStorageError( + f"missing COS avatar config: {', '.join(missing)}", + status_code=500, + ) + + def _get_client(self): + if self._client is None: + config = CosConfig( + Region=settings.cos_region, + SecretId=settings.cos_secret_id, + SecretKey=settings.cos_secret_key, + Scheme="https", + ) + self._client = CosS3Client(config) + return self._client + + def _normalize_extension(self, *, filename: str | None, content_type: str | None) -> str: + if content_type in _CONTENT_TYPE_TO_EXT: + return _CONTENT_TYPE_TO_EXT[content_type] + + suffix = Path(filename or "").suffix.lower() + if suffix in _EXTENSION_ALIASES: + return _EXTENSION_ALIASES[suffix] + + raise AvatarStorageError("unsupported avatar file type", status_code=415) + + def _build_key(self, *, user_id: int, extension: str) -> str: + prefix = settings.cos_avatar_prefix.strip("/") or "avatars" + now = datetime.now(UTC) + return ( + f"{prefix}/{user_id}/{now.strftime('%Y/%m/%d')}/" + f"{uuid4().hex}.{extension}" + ) diff --git a/mini-program/app/service/binding.py b/mini-program/app/service/binding.py index abe1611..3c23f79 100644 --- a/mini-program/app/service/binding.py +++ b/mini-program/app/service/binding.py @@ -5,11 +5,33 @@ from typing import Optional from app.dao.binding import BindingDAO +class BindingError(ValueError): + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + class BindingService: def __init__(self, db): self.dao = BindingDAO(db) - def start_bind(self, user_id: int, device_id: str, child_id: int | None = None) -> tuple[str, datetime]: + def _ensure_bindable_device(self, device_id: str, serial_number: str) -> None: + row = self.dao.get_device_auth(device_id) + if row is None: + raise BindingError("device not found in device_auth", status_code=404) + if str(row["serial_number"]) != serial_number: + raise BindingError("serial_number does not match device_id", status_code=400) + if int(row["is_active"]) != 1: + raise BindingError("device is inactive", status_code=400) + + def start_bind( + self, + user_id: int, + device_id: str, + serial_number: str, + child_id: int | None = None, + ) -> tuple[str, datetime]: + self._ensure_bindable_device(device_id, serial_number) bind_token = self.dao.start_bind(user_id, device_id, child_id) return bind_token, datetime.utcnow() @@ -31,7 +53,20 @@ class BindingService: def get_current_binding(self, user_id: int) -> Optional[Mapping]: return self.dao.get_current_by_user(user_id) - def direct_bind(self, device_id: str, child_id: int | None, user_id: int) -> Mapping: + def list_bindings(self, user_id: int, limit: int = 20, cursor: int = None) -> tuple[list, bool]: + rows = self.dao.list_by_user(user_id, limit, cursor) + has_more = len(rows) > limit + rows = rows[:limit] + return rows, has_more + + def direct_bind( + self, + device_id: str, + serial_number: str, + child_id: int | None, + user_id: int, + ) -> Mapping: + self._ensure_bindable_device(device_id, serial_number) self.dao.direct_bind(device_id, child_id, user_id) return {"device_id": device_id, "child_id": child_id} diff --git a/mini-program/app/service/im.py b/mini-program/app/service/im.py new file mode 100644 index 0000000..dcbd5ef --- /dev/null +++ b/mini-program/app/service/im.py @@ -0,0 +1,707 @@ +from dataclasses import dataclass +import json +from collections.abc import Mapping +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +try: + from app.db_compat import current_timestamp_sql, inserted_primary_key, select_for_update_clause + from app.schemas.im import ( + ChildConversationMessageItem, + DeviceMessageCreateRequest, + ParentChildMessageCreateRequest, + ) +except ModuleNotFoundError: + from db_compat import current_timestamp_sql, inserted_primary_key, select_for_update_clause + from schemas.im import ChildConversationMessageItem, DeviceMessageCreateRequest, ParentChildMessageCreateRequest + + +PARENT_PARTICIPANT_TYPE = 1 +CHILD_PARTICIPANT_TYPE = 2 + +CHILD_PEER_CONVERSATION_TYPE = 1 +PARENT_CHILD_CONVERSATION_TYPE = 2 + +CONVERSATION_TYPE_NAMES = { + CHILD_PEER_CONVERSATION_TYPE: "child_peer", + PARENT_CHILD_CONVERSATION_TYPE: "parent_child", +} + +PARTICIPANT_TYPE_NAMES = { + PARENT_PARTICIPANT_TYPE: "parent", + CHILD_PARTICIPANT_TYPE: "child", +} + + +@dataclass(frozen=True) +class DeviceIdentity: + device_id: str + child_id: int + child_name: str | None + + +@dataclass(frozen=True) +class ConversationMessageCreateResult: + idempotent: bool + conversation_id: int + conversation_type: int + message: ChildConversationMessageItem + + @property + def conversation_type_name(self) -> str: + return conversation_type_name(self.conversation_type) + + +def conversation_type_name(conversation_type: int) -> str: + return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}") + + +def participant_type_name(participant_type: int) -> str: + return PARTICIPANT_TYPE_NAMES.get(participant_type, f"unknown_{participant_type}") + + +def normalize_content_json(value: Any) -> dict[str, Any] | None: + if value is None: + return None + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return None + if isinstance(parsed, dict): + return parsed + return None + + +def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem: + return ChildConversationMessageItem( + id=int(row["id"]), + conversation_id=int(row["conversation_id"]), + seq=int(row["seq"]), + sender_type=participant_type_name(int(row["sender_type"])), + sender_id=str(row["sender_id"]), + receiver_type=participant_type_name(int(row["receiver_type"])), + receiver_id=str(row["receiver_id"]), + content_type=int(row["content_type"]), + content_text=row["content_text"], + content_json=normalize_content_json(row["content_json"]), + media_file_key=row["media_file_key"], + media_duration_ms=row["media_duration_ms"], + media_mime_type=row["media_mime_type"], + media_size_bytes=row["media_size_bytes"], + media_transcript_text=row["media_transcript_text"], + client_msg_id=row["client_msg_id"], + sender_name_snapshot=row["sender_name_snapshot"], + sender_avatar_snapshot=row["sender_avatar_snapshot"], + receiver_name_snapshot=row["receiver_name_snapshot"], + receiver_avatar_snapshot=row["receiver_avatar_snapshot"], + ext_json=normalize_content_json(row["ext_json"]), + created_at=row["created_at"], + ) + + +def assert_parent_child_access(db: Session, *, user_id: int, child_id: int) -> Mapping[str, Any]: + child_row = _get_child_row(db, child_id) + if not child_row: + raise HTTPException(status_code=404, detail="child not found") + + has_access = ( + db.execute( + text( + """ + SELECT 1 + FROM parent_child_relations + WHERE user_id = :user_id + AND child_id = :child_id + AND status = 1 + LIMIT 1 + """ + ), + {"user_id": user_id, "child_id": child_id}, + ).scalar_one_or_none() + is not None + ) + if not has_access: + raise HTTPException(status_code=403, detail="no permission to access this child") + + return child_row + + +def authenticate_device_identity(db: Session, *, device_id: str, serial_number: str) -> DeviceIdentity: + row = ( + db.execute( + text( + """ + SELECT + da.device_id, + db.child_id, + c.child_name + FROM device_auth AS da + LEFT JOIN device_bindings AS db + ON db.device_id = da.device_id + AND db.status = 1 + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE da.device_id = :device_id + AND da.serial_number = :serial_number + AND da.is_active = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "serial_number": serial_number}, + ) + .mappings() + .first() + ) + if not row: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid device credentials") + if row["child_id"] is None: + raise HTTPException(status_code=404, detail="device not bound to a child") + return DeviceIdentity( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]), + child_name=row["child_name"], + ) + + +def create_parent_child_message( + db: Session, + *, + parent_user_id: int, + child_id: int, + payload: ParentChildMessageCreateRequest, +) -> ConversationMessageCreateResult: + child_row = assert_parent_child_access(db, user_id=parent_user_id, child_id=child_id) + parent_row = _get_parent_row(db, parent_user_id) + if not parent_row: + raise HTTPException(status_code=404, detail="parent not found") + + conversation_id, idempotent = _create_message( + db=db, + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=str(child_id), + participant_b_type=PARENT_PARTICIPANT_TYPE, + participant_b_id=str(parent_user_id), + pair_key=f"{child_id}:{parent_user_id}", + sender_type=PARENT_PARTICIPANT_TYPE, + sender_id=str(parent_user_id), + receiver_type=CHILD_PARTICIPANT_TYPE, + receiver_id=str(child_id), + sender_name_snapshot=parent_row["nickname"], + sender_avatar_snapshot=parent_row["avatar_url"], + receiver_name_snapshot=child_row["child_name"], + receiver_avatar_snapshot=None, + payload=payload, + ) + message_row = _get_message_by_conversation_client_id( + db, + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if not message_row: + raise RuntimeError("message was not found after insert") + + return ConversationMessageCreateResult( + idempotent=idempotent, + conversation_id=conversation_id, + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + message=row_to_message_item(message_row), + ) + + +def create_device_message( + db: Session, + *, + device_id: str, + serial_number: str, + payload: DeviceMessageCreateRequest, +) -> tuple[DeviceIdentity, ConversationMessageCreateResult]: + device_identity = authenticate_device_identity( + db, + device_id=device_id, + serial_number=serial_number, + ) + + if payload.conversation_type == CHILD_PEER_CONVERSATION_TYPE: + if payload.peer_child_id == device_identity.child_id: + raise HTTPException(status_code=400, detail="peer_child_id must be different from current child") + sender_child_row = assert_child_exists(db, child_id=device_identity.child_id) + receiver_child_row = assert_child_exists(db, child_id=payload.peer_child_id) + participant_a_id, participant_b_id, pair_key = _build_child_peer_pair( + device_identity.child_id, + payload.peer_child_id, + ) + + conversation_id, idempotent = _create_message( + db=db, + conversation_type=CHILD_PEER_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=participant_a_id, + participant_b_type=CHILD_PARTICIPANT_TYPE, + participant_b_id=participant_b_id, + pair_key=pair_key, + sender_type=CHILD_PARTICIPANT_TYPE, + sender_id=str(device_identity.child_id), + receiver_type=CHILD_PARTICIPANT_TYPE, + receiver_id=str(payload.peer_child_id), + sender_name_snapshot=sender_child_row["child_name"], + sender_avatar_snapshot=None, + receiver_name_snapshot=receiver_child_row["child_name"], + receiver_avatar_snapshot=None, + payload=payload, + ) + else: + sender_child_row = assert_child_exists(db, child_id=device_identity.child_id) + parent_row = _get_parent_row(db, payload.parent_user_id) + if not parent_row: + raise HTTPException(status_code=404, detail="parent not found") + assert_parent_child_access(db, user_id=payload.parent_user_id, child_id=device_identity.child_id) + + conversation_id, idempotent = _create_message( + db=db, + conversation_type=PARENT_CHILD_CONVERSATION_TYPE, + participant_a_type=CHILD_PARTICIPANT_TYPE, + participant_a_id=str(device_identity.child_id), + participant_b_type=PARENT_PARTICIPANT_TYPE, + participant_b_id=str(payload.parent_user_id), + pair_key=f"{device_identity.child_id}:{payload.parent_user_id}", + sender_type=CHILD_PARTICIPANT_TYPE, + sender_id=str(device_identity.child_id), + receiver_type=PARENT_PARTICIPANT_TYPE, + receiver_id=str(payload.parent_user_id), + sender_name_snapshot=sender_child_row["child_name"], + sender_avatar_snapshot=None, + receiver_name_snapshot=parent_row["nickname"], + receiver_avatar_snapshot=parent_row["avatar_url"], + payload=payload, + ) + + message_row = _get_message_by_conversation_client_id( + db, + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if not message_row: + raise RuntimeError("message was not found after insert") + + result = ConversationMessageCreateResult( + idempotent=idempotent, + conversation_id=conversation_id, + conversation_type=payload.conversation_type, + message=row_to_message_item(message_row), + ) + return device_identity, result + + +def assert_child_exists(db: Session, *, child_id: int) -> Mapping[str, Any]: + child_row = _get_child_row(db, child_id) + if not child_row: + raise HTTPException(status_code=404, detail="child not found") + return child_row + + +def _get_child_row(db: Session, child_id: int) -> Mapping[str, Any] | None: + return ( + db.execute( + text( + """ + SELECT child_id, child_name, status + FROM children + WHERE child_id = :child_id + AND status = 1 + LIMIT 1 + """ + ), + {"child_id": child_id}, + ) + .mappings() + .first() + ) + + +def _get_parent_row(db: Session, user_id: int) -> Mapping[str, Any] | None: + return ( + db.execute( + text( + """ + SELECT user_id, nickname, avatar_url, status + FROM parents + WHERE user_id = :user_id + AND status = 1 + LIMIT 1 + """ + ), + {"user_id": user_id}, + ) + .mappings() + .first() + ) + + +def _build_child_peer_pair(child_a_id: int, child_b_id: int) -> tuple[str, str, str]: + low_id, high_id = sorted((child_a_id, child_b_id)) + participant_a_id = str(low_id) + participant_b_id = str(high_id) + return participant_a_id, participant_b_id, f"{participant_a_id}:{participant_b_id}" + + +def _build_preview(content_type: int, content_text: str | None) -> str: + if content_type == 1: + return (content_text or "").strip()[:255] + if content_type == 2: + return "[audio]" + if content_type == 3: + return "[image]" + return "[json]" + + +def _create_message( + db: Session, + *, + conversation_type: int, + participant_a_type: int, + participant_a_id: str, + participant_b_type: int, + participant_b_id: str, + pair_key: str, + sender_type: int, + sender_id: str, + receiver_type: int, + receiver_id: str, + sender_name_snapshot: str | None, + sender_avatar_snapshot: str | None, + receiver_name_snapshot: str | None, + receiver_avatar_snapshot: str | None, + payload: ParentChildMessageCreateRequest | DeviceMessageCreateRequest, +) -> tuple[int, bool]: + conversation_id = _get_or_create_conversation( + db=db, + conversation_type=conversation_type, + participant_a_type=participant_a_type, + participant_a_id=participant_a_id, + participant_b_type=participant_b_type, + participant_b_id=participant_b_id, + pair_key=pair_key, + ) + + existing = _get_message_by_conversation_client_id( + db, + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if existing: + return conversation_id, True + + now_sql = current_timestamp_sql(db) + preview = _build_preview(payload.content_type, payload.content_text) + + try: + conversation_row = _get_conversation_by_id(db, conversation_id=conversation_id, lock=True) + if not conversation_row: + raise HTTPException(status_code=404, detail="conversation not found") + next_seq = int(conversation_row["last_seq"]) + 1 + + message_id = _next_primary_key(db, "im_messages") + insert_sql = f""" + INSERT INTO im_messages ( + {'id,' if message_id is not None else ''} + conversation_id, + seq, + sender_type, + sender_id, + receiver_type, + receiver_id, + content_type, + content_text, + content_json, + media_file_key, + media_duration_ms, + media_mime_type, + media_size_bytes, + media_transcript_text, + client_msg_id, + sender_name_snapshot, + sender_avatar_snapshot, + receiver_name_snapshot, + receiver_avatar_snapshot, + ext_json, + created_at + ) VALUES ( + {':id,' if message_id is not None else ''} + :conversation_id, + :seq, + :sender_type, + :sender_id, + :receiver_type, + :receiver_id, + :content_type, + :content_text, + :content_json, + :media_file_key, + :media_duration_ms, + :media_mime_type, + :media_size_bytes, + :media_transcript_text, + :client_msg_id, + :sender_name_snapshot, + :sender_avatar_snapshot, + :receiver_name_snapshot, + :receiver_avatar_snapshot, + :ext_json, + {now_sql} + ) + """ + params: dict[str, Any] = { + "conversation_id": conversation_id, + "seq": next_seq, + "sender_type": sender_type, + "sender_id": sender_id, + "receiver_type": receiver_type, + "receiver_id": receiver_id, + "content_type": payload.content_type, + "content_text": payload.content_text, + "content_json": json.dumps(payload.content_json) if payload.content_json is not None else None, + "media_file_key": payload.media_file_key, + "media_duration_ms": payload.media_duration_ms, + "media_mime_type": payload.media_mime_type, + "media_size_bytes": payload.media_size_bytes, + "media_transcript_text": payload.media_transcript_text, + "client_msg_id": payload.client_msg_id, + "sender_name_snapshot": sender_name_snapshot, + "sender_avatar_snapshot": sender_avatar_snapshot, + "receiver_name_snapshot": receiver_name_snapshot, + "receiver_avatar_snapshot": receiver_avatar_snapshot, + "ext_json": json.dumps(payload.ext_json) if payload.ext_json is not None else None, + } + if message_id is not None: + params["id"] = message_id + + result = db.execute(text(insert_sql), params) + if message_id is None: + message_id = inserted_primary_key(result) + + db.execute( + text( + f""" + UPDATE im_conversations + SET status = 1, + last_seq = :last_seq, + message_count = message_count + 1, + last_message_preview = :last_message_preview, + last_message_at = {now_sql}, + updated_at = {now_sql} + WHERE id = :conversation_id + """ + ), + { + "conversation_id": conversation_id, + "last_seq": next_seq, + "last_message_preview": preview, + }, + ) + db.commit() + return conversation_id, False + except IntegrityError: + db.rollback() + existing = _get_message_by_conversation_client_id( + db, + conversation_id=conversation_id, + client_msg_id=payload.client_msg_id, + ) + if existing: + return conversation_id, True + raise + except Exception: + db.rollback() + raise + + +def _get_or_create_conversation( + db: Session, + *, + conversation_type: int, + participant_a_type: int, + participant_a_id: str, + participant_b_type: int, + participant_b_id: str, + pair_key: str, +) -> int: + row = _get_conversation_by_pair( + db, + conversation_type=conversation_type, + pair_key=pair_key, + lock=False, + ) + if row: + return int(row["id"]) + + now_sql = current_timestamp_sql(db) + conversation_id = _next_primary_key(db, "im_conversations") + insert_sql = f""" + INSERT INTO im_conversations ( + {'id,' if conversation_id is not None else ''} + conversation_type, + participant_a_type, + participant_a_id, + participant_b_type, + participant_b_id, + pair_key, + status, + last_seq, + message_count, + created_at, + updated_at + ) VALUES ( + {':id,' if conversation_id is not None else ''} + :conversation_type, + :participant_a_type, + :participant_a_id, + :participant_b_type, + :participant_b_id, + :pair_key, + 1, + 0, + 0, + {now_sql}, + {now_sql} + ) + """ + params = { + "conversation_type": conversation_type, + "participant_a_type": participant_a_type, + "participant_a_id": participant_a_id, + "participant_b_type": participant_b_type, + "participant_b_id": participant_b_id, + "pair_key": pair_key, + } + if conversation_id is not None: + params["id"] = conversation_id + + try: + result = db.execute(text(insert_sql), params) + if conversation_id is not None: + return conversation_id + return inserted_primary_key(result) + except IntegrityError: + db.rollback() + row = _get_conversation_by_pair( + db, + conversation_type=conversation_type, + pair_key=pair_key, + lock=False, + ) + if row: + return int(row["id"]) + raise + + +def _get_conversation_by_pair( + db: Session, + *, + conversation_type: int, + pair_key: str, + lock: bool, +) -> Mapping[str, Any] | None: + lock_clause = select_for_update_clause(db) if lock else "" + return ( + db.execute( + text( + f""" + SELECT id, conversation_type, last_seq, status + FROM im_conversations + WHERE conversation_type = :conversation_type + AND pair_key = :pair_key + LIMIT 1{lock_clause} + """ + ), + {"conversation_type": conversation_type, "pair_key": pair_key}, + ) + .mappings() + .first() + ) + + +def _get_conversation_by_id( + db: Session, + *, + conversation_id: int, + lock: bool, +) -> Mapping[str, Any] | None: + lock_clause = select_for_update_clause(db) if lock else "" + return ( + db.execute( + text( + f""" + SELECT id, last_seq, message_count, status + FROM im_conversations + WHERE id = :conversation_id + LIMIT 1{lock_clause} + """ + ), + {"conversation_id": conversation_id}, + ) + .mappings() + .first() + ) + + +def _get_message_by_conversation_client_id( + db: Session, + *, + conversation_id: int, + client_msg_id: str, +) -> Mapping[str, Any] | None: + return ( + db.execute( + text( + """ + SELECT + id, + conversation_id, + seq, + sender_type, + sender_id, + receiver_type, + receiver_id, + content_type, + content_text, + content_json, + media_file_key, + media_duration_ms, + media_mime_type, + media_size_bytes, + media_transcript_text, + client_msg_id, + sender_name_snapshot, + sender_avatar_snapshot, + receiver_name_snapshot, + receiver_avatar_snapshot, + ext_json, + created_at + FROM im_messages + WHERE conversation_id = :conversation_id + AND client_msg_id = :client_msg_id + LIMIT 1 + """ + ), + {"conversation_id": conversation_id, "client_msg_id": client_msg_id}, + ) + .mappings() + .first() + ) + + +def _next_primary_key(db: Session, table_name: str) -> int | None: + if db.get_bind() is None or db.get_bind().dialect.name != "sqlite": + return None + return int( + db.execute(text(f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table_name}")).scalar_one() + ) diff --git a/mini-program/app/service/location.py b/mini-program/app/service/location.py new file mode 100644 index 0000000..9096ae5 --- /dev/null +++ b/mini-program/app/service/location.py @@ -0,0 +1,327 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import text +from sqlalchemy.orm import Session + +try: + from app.db_compat import current_timestamp_sql, select_for_update_clause + from app.schemas.location import DeviceLocationReportRequest + from app.service.im import DeviceIdentity, authenticate_device_identity +except ModuleNotFoundError: + from db_compat import current_timestamp_sql, select_for_update_clause + from schemas.location import DeviceLocationReportRequest + from service.im import DeviceIdentity, authenticate_device_identity + + +@dataclass(frozen=True) +class ParentDeviceAccess: + device_id: str + child_id: int + child_name: str | None + + +def assert_parent_device_access(db: Session, *, device_id: str, user_id: int) -> ParentDeviceAccess: + row = ( + db.execute( + text( + """ + SELECT + db.device_id, + db.child_id, + c.child_name + FROM device_bindings AS db + LEFT JOIN children AS c + ON c.child_id = db.child_id + AND c.status = 1 + WHERE db.device_id = :device_id + AND db.owner_user_id = :user_id + AND db.status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id, "user_id": user_id}, + ) + .mappings() + .first() + ) + if not row: + raise HTTPException(status_code=404, detail="device not found") + if row["child_id"] is None: + raise HTTPException(status_code=404, detail="device not bound to a child") + + return ParentDeviceAccess( + device_id=str(row["device_id"]), + child_id=int(row["child_id"]), + child_name=row["child_name"], + ) + + +def report_device_location( + db: Session, + *, + device_id: str, + serial_number: str, + payload: DeviceLocationReportRequest, +) -> tuple[DeviceIdentity, Mapping[str, Any]]: + device_identity = authenticate_device_identity( + db=db, + device_id=device_id, + serial_number=serial_number, + ) + now_sql = current_timestamp_sql(db) + child_id = device_identity.child_id + + try: + current_row = _get_current_location_row(db, child_id=child_id, lock=True) + params = { + "child_id": child_id, + "device_id": device_identity.device_id, + "coord_type": payload.coord_type, + "lat": payload.lat, + "lng": payload.lng, + "accuracy_m": payload.accuracy_m, + "altitude_m": payload.altitude_m, + "speed_mps": payload.speed_mps, + "heading_deg": payload.heading_deg, + "source": payload.source, + "battery_pct": payload.battery_pct, + "device_time": payload.device_time, + } + if current_row: + db.execute( + text( + f""" + UPDATE child_location_current + SET device_id = :device_id, + coord_type = :coord_type, + lat = :lat, + lng = :lng, + accuracy_m = :accuracy_m, + altitude_m = :altitude_m, + speed_mps = :speed_mps, + heading_deg = :heading_deg, + source = :source, + battery_pct = :battery_pct, + device_time = :device_time, + server_time = {now_sql}, + updated_at = {now_sql} + WHERE child_id = :child_id + """ + ), + params, + ) + else: + db.execute( + text( + f""" + INSERT INTO child_location_current ( + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + updated_at + ) VALUES ( + :child_id, + :device_id, + :coord_type, + :lat, + :lng, + :accuracy_m, + :altitude_m, + :speed_mps, + :heading_deg, + :source, + :battery_pct, + :device_time, + {now_sql}, + {now_sql} + ) + """ + ), + params, + ) + + history_id = _next_primary_key(db, "child_location_history") + insert_history_sql = f""" + INSERT INTO child_location_history ( + {'id,' if history_id is not None else ''} + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + created_at + ) VALUES ( + {':id,' if history_id is not None else ''} + :child_id, + :device_id, + :coord_type, + :lat, + :lng, + :accuracy_m, + :altitude_m, + :speed_mps, + :heading_deg, + :source, + :battery_pct, + :device_time, + {now_sql}, + {now_sql} + ) + """ + history_params = dict(params) + if history_id is not None: + history_params["id"] = history_id + db.execute(text(insert_history_sql), history_params) + db.commit() + except Exception: + db.rollback() + raise + + current_row = _get_current_location_row(db, child_id=child_id, lock=False) + if not current_row: + raise RuntimeError("current location not found after report") + return device_identity, current_row + + +def get_device_current_location( + db: Session, + *, + device_id: str, + user_id: int, +) -> Mapping[str, Any]: + access = assert_parent_device_access(db, device_id=device_id, user_id=user_id) + row = _get_current_location_row(db, child_id=access.child_id, lock=False) + if not row or str(row["device_id"]) != device_id: + raise HTTPException(status_code=404, detail="location not found") + return {**row, "child_name": access.child_name} + + +def get_device_trajectory( + db: Session, + *, + device_id: str, + user_id: int, + start_at: datetime | None, + end_at: datetime | None, + limit: int, +) -> tuple[ParentDeviceAccess, list[Mapping[str, Any]]]: + access = assert_parent_device_access(db, device_id=device_id, user_id=user_id) + params: dict[str, Any] = { + "device_id": device_id, + "child_id": access.child_id, + "fetch_limit": limit, + } + where = """ + device_id = :device_id + AND child_id = :child_id + """ + if start_at is not None: + where += " AND device_time >= :start_at" + params["start_at"] = start_at + if end_at is not None: + where += " AND device_time <= :end_at" + params["end_at"] = end_at + + rows = ( + db.execute( + text( + f""" + SELECT + id, + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + created_at + FROM child_location_history + WHERE {where} + ORDER BY device_time DESC, id DESC + LIMIT :fetch_limit + """ + ), + params, + ) + .mappings() + .all() + ) + rows = list(rows) + rows.reverse() + return access, rows + + +def _get_current_location_row( + db: Session, + *, + child_id: int, + lock: bool, +) -> Mapping[str, Any] | None: + lock_clause = select_for_update_clause(db) if lock else "" + return ( + db.execute( + text( + f""" + SELECT + child_id, + device_id, + coord_type, + lat, + lng, + accuracy_m, + altitude_m, + speed_mps, + heading_deg, + source, + battery_pct, + device_time, + server_time, + updated_at + FROM child_location_current + WHERE child_id = :child_id + LIMIT 1{lock_clause} + """ + ), + {"child_id": child_id}, + ) + .mappings() + .first() + ) + + +def _next_primary_key(db: Session, table_name: str) -> int | None: + bind = db.get_bind() + if bind is None or bind.dialect.name != "sqlite": + return None + return int( + db.execute(text(f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table_name}")).scalar_one() + ) diff --git a/mini-program/app/service/parent.py b/mini-program/app/service/parent.py index a540b45..8a261d4 100644 --- a/mini-program/app/service/parent.py +++ b/mini-program/app/service/parent.py @@ -2,12 +2,14 @@ from collections.abc import Mapping from typing import Optional from app.dao.parent import ParentDAO -from app.service import get_db_session +from app.service.avatar_storage import AvatarStorageService +from app.settings import settings class ParentService: - def __init__(self, db): + def __init__(self, db, avatar_storage: AvatarStorageService | None = None): self.dao = ParentDAO(db) + self.avatar_storage = avatar_storage or AvatarStorageService() def create( self, @@ -17,10 +19,10 @@ class ParentService: avatar_url: Optional[str] = None, ) -> Mapping: user_id = self.dao.upsert(openid, unionid, nickname, avatar_url) - return self.dao.get_by_id(user_id) + return self.get(user_id) def get(self, user_id: int) -> Optional[Mapping]: - return self.dao.get_by_id(user_id) + return self._present_parent(self.dao.get_by_id(user_id)) def update( self, @@ -30,4 +32,71 @@ class ParentService: phone: Optional[str] = None, ) -> Mapping: self.dao.update(user_id, nickname, avatar_url, phone) - return self.dao.get_by_id(user_id) \ No newline at end of file + return self.get(user_id) + + def upload_avatar( + self, + *, + user_id: int, + filename: str | None, + content_type: str | None, + content: bytes, + ) -> Optional[Mapping]: + existing = self.dao.get_by_id(user_id) + if not existing: + return None + + stored = self.avatar_storage.upload_avatar( + user_id=user_id, + filename=filename, + content_type=content_type, + content=content, + ) + old_avatar_file_key = existing.get("avatar_file_key") + + try: + self.dao.set_avatar_file_key(user_id, stored.file_key) + except Exception: + try: + self.avatar_storage.delete_avatar(stored.file_key) + except Exception: + pass + raise + + if old_avatar_file_key and old_avatar_file_key != stored.file_key: + try: + self.avatar_storage.delete_avatar(old_avatar_file_key) + except Exception: + pass + + return self.get(user_id) + + def get_avatar_download(self, user_id: int) -> Optional[dict]: + parent = self.dao.get_by_id(user_id) + if not parent: + return None + + avatar_file_key = parent.get("avatar_file_key") + if avatar_file_key: + return { + "avatar_url": self.avatar_storage.get_avatar_url(avatar_file_key), + "expires_in": settings.cos_avatar_url_expire_seconds, + } + + avatar_url = parent.get("avatar_url") + if avatar_url: + return { + "avatar_url": avatar_url, + "expires_in": None, + } + return None + + def _present_parent(self, parent: Optional[Mapping]) -> Optional[dict]: + if not parent: + return None + + data = dict(parent) + avatar_file_key = data.get("avatar_file_key") + if avatar_file_key: + data["avatar_url"] = self.avatar_storage.get_avatar_url(avatar_file_key) + return data diff --git a/mini-program/app/service/wechat_login.py b/mini-program/app/service/wechat_login.py new file mode 100644 index 0000000..44c9c03 --- /dev/null +++ b/mini-program/app/service/wechat_login.py @@ -0,0 +1,125 @@ +import logging +from dataclasses import dataclass + +import httpx + +try: + from app.settings import settings +except ModuleNotFoundError: + from settings import settings + + +logger = logging.getLogger("app.wechat_login") + +INVALID_CODE_ERRCODES = {40029, 40163} +MISCONFIGURED_APP_ERRCODES = {40013, 40125} + + +@dataclass(frozen=True) +class WechatCodeSession: + openid: str + session_key: str + unionid: str | None = None + + +class WechatAuthError(Exception): + def __init__(self, detail: str, *, status_code: int, errcode: int | None = None): + super().__init__(detail) + self.status_code = status_code + self.errcode = errcode + + +class WechatAuthService: + def __init__(self, client: httpx.Client | None = None): + self._client = client + + def exchange_code(self, code: str) -> WechatCodeSession: + if not settings.wechat_app_id or not settings.wechat_app_secret: + raise WechatAuthError("wechat login is not configured", status_code=503) + + response = self._request_code2session(code) + data = self._parse_response_json(response) + + errcode = self._parse_errcode(data.get("errcode")) + if errcode not in (None, 0): + errmsg = data.get("errmsg") + logger.warning( + "wechat code2session rejected login code", + extra={ + "event": "wechat_code2session_rejected", + "errcode": errcode, + "errmsg": errmsg, + }, + ) + raise self._map_exchange_error(errcode) + + openid = data.get("openid") + session_key = data.get("session_key") + unionid = data.get("unionid") + + if not isinstance(openid, str) or not openid: + raise WechatAuthError("wechat login response missing openid", status_code=502) + if not isinstance(session_key, str) or not session_key: + raise WechatAuthError("wechat login response missing session_key", status_code=502) + if not isinstance(unionid, str) or not unionid: + unionid = None + + return WechatCodeSession(openid=openid, session_key=session_key, unionid=unionid) + + def _request_code2session(self, code: str) -> httpx.Response: + params = { + "appid": settings.wechat_app_id, + "secret": settings.wechat_app_secret, + "js_code": code, + "grant_type": "authorization_code", + } + + client = self._client + owns_client = client is None + if client is None: + client = httpx.Client( + base_url=settings.wechat_api_base_url.rstrip("/"), + timeout=settings.wechat_http_timeout_seconds, + ) + + try: + response = client.get("/sns/jscode2session", params=params) + response.raise_for_status() + return response + except httpx.HTTPError as exc: + logger.warning( + "wechat code2session request failed", + extra={"event": "wechat_code2session_request_failed"}, + ) + raise WechatAuthError("wechat login service unavailable", status_code=502) from exc + finally: + if owns_client: + client.close() + + def _parse_response_json(self, response: httpx.Response) -> dict: + try: + data = response.json() + except ValueError as exc: + raise WechatAuthError("invalid response from wechat login service", status_code=502) from exc + if not isinstance(data, dict): + raise WechatAuthError("invalid response from wechat login service", status_code=502) + return data + + def _parse_errcode(self, value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def _map_exchange_error(self, errcode: int) -> WechatAuthError: + if errcode in INVALID_CODE_ERRCODES: + return WechatAuthError("invalid or expired wechat login code", status_code=401, errcode=errcode) + if errcode in MISCONFIGURED_APP_ERRCODES: + return WechatAuthError("wechat login is not configured correctly", status_code=503, errcode=errcode) + return WechatAuthError("wechat login service unavailable", status_code=502, errcode=errcode) + + +def get_wechat_auth_service() -> WechatAuthService: + return WechatAuthService() diff --git a/mini-program/app/settings.py b/mini-program/app/settings.py index 58484bb..8ad0c74 100644 --- a/mini-program/app/settings.py +++ b/mini-program/app/settings.py @@ -5,10 +5,12 @@ from urllib.parse import quote_plus from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +ENV_FILE = Path(__file__).resolve().parent.parent / ".env" + class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", + env_file=str(ENV_FILE), env_file_encoding="utf-8", extra="ignore", ) @@ -27,8 +29,34 @@ class Settings(BaseSettings): db_port: int = Field(default=3306, validation_alias="DB_PORT") db_user: str = Field(default="root", validation_alias="DB_USER") db_password: str = Field(default="", validation_alias="DB_PASSWORD") - db_name: str = Field(default="mini_program", validation_alias="DB_NAME") + db_name: str = Field(default="talkingq", validation_alias="DB_NAME") + db_auto_init_tables: bool = Field(default=False, validation_alias="DB_AUTO_INIT_TABLES") db_path: str = Field(default="./data.db", validation_alias="DB_PATH") + wechat_app_id: str = Field(default="", validation_alias="WECHAT_APP_ID") + wechat_app_secret: str = Field(default="", validation_alias="WECHAT_APP_SECRET") + wechat_api_base_url: str = Field( + default="https://api.weixin.qq.com", + validation_alias="WECHAT_API_BASE_URL", + ) + wechat_http_timeout_seconds: float = Field( + default=5.0, + validation_alias="WECHAT_HTTP_TIMEOUT_SECONDS", + ) + cos_secret_id: str = Field(default="", validation_alias="COS_SECRET_ID") + cos_secret_key: str = Field(default="", validation_alias="COS_SECRET_KEY") + cos_region: str = Field(default="", validation_alias="COS_REGION") + cos_bucket_message: str = Field(default="", validation_alias="COS_BUCKET_MESSAGE") + cos_bucket_ava: str = Field(default="", validation_alias="COS_BUCKET_AVA") + cos_public_base_url: str = Field(default="", validation_alias="COS_PUBLIC_BASE_URL") + cos_avatar_prefix: str = Field(default="avatars/", validation_alias="COS_AVATAR_PREFIX") + cos_avatar_url_expire_seconds: int = Field( + default=86400, + validation_alias="COS_AVATAR_URL_EXPIRE_SECONDS", + ) + cos_avatar_max_bytes: int = Field( + default=2 * 1024 * 1024, + validation_alias="COS_AVATAR_MAX_BYTES", + ) jwt_secret: str = Field( default="dev_only_change_jwt_secret", validation_alias="JWT_SECRET", @@ -64,6 +92,10 @@ class Settings(BaseSettings): return self.sqlite_dsn return self.mysql_dsn + @property + def uses_shared_talkingq_db(self) -> bool: + return self.db_type == "mysql" and self.db_name == "talkingq" + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/mini-program/database/sql/003_new_tables.sql b/mini-program/database/init.sql similarity index 72% rename from mini-program/database/sql/003_new_tables.sql rename to mini-program/database/init.sql index 6981c73..d26de6c 100644 --- a/mini-program/database/sql/003_new_tables.sql +++ b/mini-program/database/init.sql @@ -1,26 +1,36 @@ --- New tables per design.md --- Supports both MySQL and SQLite +-- Current MySQL schema for mini-program --- parents table +-- Deprecated for shared deployments. +-- If `mini-program` and `talkingq-url` run on the same MySQL instance, +-- use `database/talkingq_shared_schema.sql` to initialize the single +-- source-of-truth database `talkingq`. +-- +-- This file is kept only for legacy isolated development of mini-program. + +CREATE DATABASE IF NOT EXISTS mini_program + DEFAULT CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE mini_program; + +-- parents CREATE TABLE IF NOT EXISTS parents ( user_id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) UNIQUE NOT NULL, unionid VARCHAR(64), nickname VARCHAR(64), avatar_url VARCHAR(255), + avatar_file_key VARCHAR(255), phone VARCHAR(20), status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- SQLite uses TRIGGER for updated_at, MySQL uses ON UPDATE --- For SQLite compatibility, we'll handle in app layer +CREATE INDEX idx_parents_unionid ON parents(unionid); +CREATE INDEX idx_parents_phone ON parents(phone); -CREATE INDEX IF NOT EXISTS idx_parents_unionid ON parents(unionid); -CREATE INDEX IF NOT EXISTS idx_parents_phone ON parents(phone); - --- children table +-- children CREATE TABLE IF NOT EXISTS children ( child_id BIGINT PRIMARY KEY AUTO_INCREMENT, child_name VARCHAR(32) NOT NULL, @@ -29,9 +39,9 @@ CREATE TABLE IF NOT EXISTS children ( status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- parent_child_relations table +-- parent_child_relations CREATE TABLE IF NOT EXISTS parent_child_relations ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, @@ -41,10 +51,13 @@ CREATE TABLE IF NOT EXISTS parent_child_relations ( status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, child_id) -); + CONSTRAINT uq_user_child UNIQUE (user_id, child_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- device_bindings table +CREATE INDEX idx_pcr_user_id ON parent_child_relations(user_id); +CREATE INDEX idx_pcr_child_id ON parent_child_relations(child_id); + +-- device_bindings CREATE TABLE IF NOT EXISTS device_bindings ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_id VARCHAR(64) NOT NULL, @@ -55,13 +68,13 @@ CREATE TABLE IF NOT EXISTS device_bindings ( unbound_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (device_id), - UNIQUE (child_id) -); + CONSTRAINT uq_device_binding_device UNIQUE (device_id), + CONSTRAINT uq_device_binding_child UNIQUE (child_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE INDEX IF NOT EXISTS idx_device_bindings_owner_user_id ON device_bindings(owner_user_id); +CREATE INDEX idx_device_bindings_owner_user_id ON device_bindings(owner_user_id); --- device_bind_sessions table +-- device_bind_sessions CREATE TABLE IF NOT EXISTS device_bind_sessions ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bind_token CHAR(36) UNIQUE NOT NULL, @@ -78,9 +91,9 @@ CREATE TABLE IF NOT EXISTS device_bind_sessions ( consumed_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- device_bind_history table +-- device_bind_history CREATE TABLE IF NOT EXISTS device_bind_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_id VARCHAR(64) NOT NULL, @@ -92,9 +105,9 @@ CREATE TABLE IF NOT EXISTS device_bind_history ( unbound_at DATETIME, unbind_reason VARCHAR(191), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- cards table +-- cards CREATE TABLE IF NOT EXISTS cards ( card_id BIGINT PRIMARY KEY AUTO_INCREMENT, card_uuid VARCHAR(64) UNIQUE NOT NULL, @@ -104,9 +117,9 @@ CREATE TABLE IF NOT EXISTS cards ( total_swaps INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- device_settings table +-- device_settings CREATE TABLE IF NOT EXISTS device_settings ( setting_id BIGINT PRIMARY KEY AUTO_INCREMENT, device_id VARCHAR(64) NOT NULL UNIQUE, @@ -119,9 +132,9 @@ CREATE TABLE IF NOT EXISTS device_settings ( disable_weekdays VARCHAR(32), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- im_conversations table +-- im_conversations CREATE TABLE IF NOT EXISTS im_conversations ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_type TINYINT UNSIGNED NOT NULL, @@ -138,14 +151,14 @@ CREATE TABLE IF NOT EXISTS im_conversations ( ext_json JSON, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (conversation_type, pair_key) -); + CONSTRAINT uq_conv_type_pair UNIQUE (conversation_type, pair_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE INDEX IF NOT EXISTS idx_im_conv_participant_a ON im_conversations(participant_a_type, participant_a_id); -CREATE INDEX IF NOT EXISTS idx_im_conv_participant_b ON im_conversations(participant_b_type, participant_b_id); -CREATE INDEX IF NOT EXISTS idx_im_conv_last_message_at ON im_conversations(last_message_at); +CREATE INDEX idx_im_conv_participant_a ON im_conversations(participant_a_type, participant_a_id); +CREATE INDEX idx_im_conv_participant_b ON im_conversations(participant_b_type, participant_b_id); +CREATE INDEX idx_im_conv_last_message_at ON im_conversations(last_message_at); --- im_messages table +-- im_messages CREATE TABLE IF NOT EXISTS im_messages ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_id BIGINT UNSIGNED NOT NULL, @@ -170,14 +183,14 @@ CREATE TABLE IF NOT EXISTS im_messages ( ext_json JSON, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, - UNIQUE (conversation_id, seq), - UNIQUE (conversation_id, client_msg_id) -); + CONSTRAINT uq_im_msg_conv_seq UNIQUE (conversation_id, seq), + CONSTRAINT uq_im_msg_client UNIQUE (conversation_id, client_msg_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE INDEX IF NOT EXISTS idx_im_msg_sender ON im_messages(sender_type, sender_id, created_at); -CREATE INDEX IF NOT EXISTS idx_im_msg_receiver ON im_messages(receiver_type, receiver_id, created_at); +CREATE INDEX idx_im_msg_sender ON im_messages(sender_type, sender_id, created_at); +CREATE INDEX idx_im_msg_receiver ON im_messages(receiver_type, receiver_id, created_at); --- child_location_current table +-- child_location_current CREATE TABLE IF NOT EXISTS child_location_current ( child_id BIGINT UNSIGNED PRIMARY KEY, device_id VARCHAR(64) NOT NULL, @@ -193,9 +206,9 @@ CREATE TABLE IF NOT EXISTS child_location_current ( device_time DATETIME NOT NULL, server_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- child_location_history table +-- child_location_history CREATE TABLE IF NOT EXISTS child_location_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, child_id BIGINT UNSIGNED NOT NULL, @@ -212,6 +225,6 @@ CREATE TABLE IF NOT EXISTS child_location_history ( device_time DATETIME NOT NULL, server_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE INDEX IF NOT EXISTS idx_child_loc_hist_child ON child_location_history(child_id, created_at); +CREATE INDEX idx_child_loc_hist_child ON child_location_history(child_id, created_at); diff --git a/mini-program/database/sql/001_init_chat.sql b/mini-program/database/sql/001_init_chat.sql deleted file mode 100644 index a8c6a02..0000000 --- a/mini-program/database/sql/001_init_chat.sql +++ /dev/null @@ -1,305 +0,0 @@ --- Chat schema and seed data for mini-program --- Target: MySQL 8+ - -CREATE DATABASE IF NOT EXISTS mini_program - DEFAULT CHARACTER SET utf8mb4 - COLLATE utf8mb4_unicode_ci; - -USE mini_program; - -SET FOREIGN_KEY_CHECKS=0; -DROP TABLE IF EXISTS chat_message; -DROP TABLE IF EXISTS chat_conversation_member; -DROP TABLE IF EXISTS chat_conversation; -DROP TABLE IF EXISTS chat_user_auth; -DROP TABLE IF EXISTS chat_user; -SET FOREIGN_KEY_CHECKS=1; - -CREATE TABLE IF NOT EXISTS chat_user ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - nickname VARCHAR(64) NULL, - avatar_file_key VARCHAR(255) NULL COMMENT 'Stored file key, not URL', - status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1-active 2-disabled 3-deleted', - last_login_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (id), - KEY idx_status (status), - KEY idx_created_at (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS chat_user_auth ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id BIGINT UNSIGNED NOT NULL, - auth_type TINYINT UNSIGNED NOT NULL COMMENT '1-wechat_miniapp_openid 2-wechat_unionid 3-phone_sms 4-email_magic_link 5-oauth 6-username_password', - auth_identifier VARCHAR(191) NOT NULL COMMENT 'openid/unionid/phone/email/oauth-sub/username', - password_hash CHAR(64) NULL COMMENT 'sha256 hex for demo only; production should use bcrypt/argon2', - credential_json JSON NULL COMMENT 'Extra auth payload, e.g. app_id/provider/phone_area', - is_primary TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1-primary login identity 0-secondary', - status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1-active 2-disabled 3-revoked', - last_login_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (id), - UNIQUE KEY uk_auth_type_identifier (auth_type, auth_identifier), - KEY idx_user_status (user_id, status), - KEY idx_last_login_at (last_login_at), - CONSTRAINT fk_user_auth_user - FOREIGN KEY (user_id) REFERENCES chat_user(id) - ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS chat_conversation ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_low_id BIGINT UNSIGNED NOT NULL COMMENT 'Smaller user_id in direct chat pair', - user_high_id BIGINT UNSIGNED NOT NULL COMMENT 'Larger user_id in direct chat pair', - status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1-active 2-archived', - last_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, - message_count BIGINT UNSIGNED NOT NULL DEFAULT 0, - last_message_preview VARCHAR(255) NULL, - last_message_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (id), - UNIQUE KEY uk_direct_pair (user_low_id, user_high_id), - KEY idx_last_message_at (last_message_at), - KEY idx_user_low_id (user_low_id), - KEY idx_user_high_id (user_high_id), - CONSTRAINT fk_conv_user_low - FOREIGN KEY (user_low_id) REFERENCES chat_user(id) - ON DELETE CASCADE, - CONSTRAINT fk_conv_user_high - FOREIGN KEY (user_high_id) REFERENCES chat_user(id) - ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS chat_message ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - conversation_id BIGINT UNSIGNED NOT NULL, - seq BIGINT UNSIGNED NOT NULL COMMENT 'Conversation-local incremental sequence', - sender_user_id BIGINT UNSIGNED NULL COMMENT 'Null means assistant/system sender', - role TINYINT UNSIGNED NOT NULL COMMENT '1-user 2-assistant 3-system 4-tool', - content_type TINYINT UNSIGNED NOT NULL COMMENT '1-text 2-audio 3-image 4-json', - content_text MEDIUMTEXT NULL, - content_json JSON NULL, - media_file_key VARCHAR(255) NULL COMMENT 'Stored file key, not URL', - media_duration_ms INT UNSIGNED NULL, - client_msg_id VARCHAR(64) NULL COMMENT 'Idempotency key from client', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - deleted_at DATETIME(3) NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_conv_seq (conversation_id, seq), - UNIQUE KEY uk_conv_client_msg (conversation_id, client_msg_id), - KEY idx_conv_created (conversation_id, created_at), - KEY idx_sender_created (sender_user_id, created_at), - CONSTRAINT fk_msg_conv - FOREIGN KEY (conversation_id) REFERENCES chat_conversation(id) - ON DELETE CASCADE, - CONSTRAINT fk_msg_sender_user - FOREIGN KEY (sender_user_id) REFERENCES chat_user(id) - ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -INSERT INTO chat_user ( - id, - nickname, - avatar_file_key, - status, - last_login_at, - created_at, - updated_at -) -VALUES - ( - 1, - 'Parent A', - 'avatars/parent-a.png', - 1, - '2026-03-25 09:58:00.000', - '2026-03-25 09:00:00.000', - '2026-03-25 09:58:00.000' - ), - ( - 2, - 'Parent B', - 'avatars/parent-b.png', - 1, - '2026-03-25 10:01:00.000', - '2026-03-25 09:20:00.000', - '2026-03-25 10:01:00.000' - ), - ( - 3, - 'Parent C', - 'avatars/parent-c.png', - 1, - '2026-03-25 21:11:00.000', - '2026-03-25 20:30:00.000', - '2026-03-25 21:11:00.000' - ); - -INSERT INTO chat_user_auth ( - id, - user_id, - auth_type, - auth_identifier, - password_hash, - credential_json, - is_primary, - status, - last_login_at, - created_at, - updated_at -) -VALUES - ( - 1, - 1, - 6, - 'test1', - SHA2('123', 256), - JSON_OBJECT('provider', 'local_account'), - 1, - 1, - '2026-03-25 09:58:00.000', - '2026-03-25 09:00:00.000', - '2026-03-25 09:58:00.000' - ), - ( - 2, - 2, - 6, - 'test2', - SHA2('123', 256), - JSON_OBJECT('provider', 'local_account'), - 1, - 1, - '2026-03-25 10:01:00.000', - '2026-03-25 09:20:00.000', - '2026-03-25 10:01:00.000' - ), - ( - 3, - 3, - 6, - 'test3', - SHA2('123', 256), - JSON_OBJECT('provider', 'local_account'), - 1, - 1, - '2026-03-25 21:11:00.000', - '2026-03-25 20:30:00.000', - '2026-03-25 21:11:00.000' - ); - -INSERT INTO chat_conversation ( - id, - user_low_id, - user_high_id, - status, - last_seq, - message_count, - last_message_preview, - last_message_at, - created_at, - updated_at -) -VALUES - ( - 1, - 1, - 2, - 1, - 5, - 5, - 'user: Thanks, I understand now.', - '2026-03-25 10:00:40.000', - '2026-03-25 10:00:00.000', - '2026-03-25 10:00:40.000' - ); - -INSERT INTO chat_message ( - id, - conversation_id, - seq, - sender_user_id, - role, - content_type, - content_text, - content_json, - media_file_key, - media_duration_ms, - client_msg_id, - created_at -) -VALUES - ( - 1001, - 1, - 1, - 1, - 1, - 1, - 'Hi, did you finish homework?', - JSON_OBJECT('lang', 'en'), - NULL, - NULL, - 'c1-m1', - '2026-03-25 10:00:05.000' - ), - ( - 1002, - 1, - 2, - 2, - 1, - 1, - 'Almost done, one question left.', - JSON_OBJECT('lang', 'en'), - NULL, - NULL, - 'c1-m2', - '2026-03-25 10:00:12.000' - ), - ( - 1003, - 1, - 3, - NULL, - 2, - 1, - 'I can help explain the last question.', - JSON_OBJECT('tone', 'assistant'), - NULL, - NULL, - 'c1-m3', - '2026-03-25 10:00:20.000' - ), - ( - 1004, - 1, - 4, - 1, - 1, - 1, - 'Great, explain how to solve it.', - JSON_OBJECT('lang', 'en'), - NULL, - NULL, - 'c1-m4', - '2026-03-25 10:00:30.000' - ), - ( - 1005, - 1, - 5, - 2, - 1, - 1, - 'Thanks, I understand now.', - JSON_OBJECT('lang', 'en'), - NULL, - NULL, - 'c1-m5', - '2026-03-25 10:00:40.000' - ); diff --git a/mini-program/database/sql/002_new_db.sql b/mini-program/database/sql/002_new_db.sql deleted file mode 100644 index 4a2aafe..0000000 --- a/mini-program/database/sql/002_new_db.sql +++ /dev/null @@ -1,227 +0,0 @@ --- 家长微信账号表 -CREATE TABLE parents ( - user_id BIGINT PRIMARY KEY AUTO_INCREMENT, - openid VARCHAR(64) UNIQUE NOT NULL COMMENT '微信openid', - unionid VARCHAR(64) COMMENT '微信unionid', - nickname VARCHAR(64) COMMENT '家长昵称', - avatar_url VARCHAR(255), - phone VARCHAR(20), - status TINYINT DEFAULT 1 COMMENT '0禁用 1正常', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_unionid (unionid), - INDEX idx_phone (phone) -) ENGINE=InnoDB COMMENT='家长用户表'; - -CREATE TABLE children ( - profile_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '档案主键', - user_id BIGINT NOT NULL COMMENT '所属家长ID', - - -- 核心资料 - child_name VARCHAR(32) NOT NULL COMMENT '儿童昵称(设备唤醒名)', - child_gender TINYINT DEFAULT 2 COMMENT '性别:0女 1男 2保密', - child_birthday DATE COMMENT '出生日期', - child_age TINYINT UNSIGNED GENERATED ALWAYS AS ( - TIMESTAMPDIFF(YEAR, child_birthday, CURDATE()) - ) STORED COMMENT '自动计算年龄(用于适龄内容过滤)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - -- 约束与索引 - FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE, -)ENGINE=InnoDB COMMENT='儿童档案表(一个家长可创建多个档案)'; - -CREATE TABLE cards ( - card_id BIGINT PRIMARY KEY AUTO_INCREMENT, - card_uuid VARCHAR(64) UNIQUE NOT NULL COMMENT 'NFC卡片UUID', - device_id BIGINT UNIQUE COMMENT '绑定设备ID', - card_name VARCHAR(64) COMMENT '名片名称', - status TINYINT DEFAULT 0 COMMENT '0未绑定 1已绑定 2挂失', - total_swaps INT DEFAULT 0 COMMENT '交换次数', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - FOREIGN KEY (device_id) REFERENCES devices(device_id), - INDEX idx_uuid (card_uuid), - INDEX idx_device (device_id) -) ENGINE=InnoDB COMMENT='实体交友名片表'; --- Todo 待修改 -CREATE TABLE device_settings ( - setting_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '设置主键', - device_id BIGINT NOT NULL UNIQUE COMMENT '设备ID(一对一)', - - -- 休眠与时间管理(防熬夜) - sleep_mode TINYINT DEFAULT 0 COMMENT '休眠模式:0关闭 1定时休眠 2连续使用限制', - sleep_start TIME COMMENT '定时休眠开始时间,如 22:00:00', - sleep_end TIME COMMENT '定时休眠结束时间,如 07:00:00', - timezone VARCHAR(32) DEFAULT 'Asia/Shanghai' COMMENT '时区,解决跨时区问题', - - -- 音量与硬件控制 - volume TINYINT UNSIGNED DEFAULT 80 COMMENT '系统音量 0-100', - brightness TINYINT UNSIGNED DEFAULT 100 COMMENT '指示灯亮度 0-100(0为关闭)', - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - -) ENGINE=InnoDB COMMENT='设备设置表(家长远程管控配置)'; - --- 初始化数据库表 - --- 创建device_configs表 -CREATE TABLE IF NOT EXISTS `device_configs` ( - `id` INT NOT NULL AUTO_INCREMENT, - `device_id` VARCHAR(64) NOT NULL, - `selected_role_key` VARCHAR(64) NOT NULL, - `preferred_language` VARCHAR(10) NULL, - `volume` INT NULL, - `last_update_time` FLOAT NOT NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE INDEX `device_id_UNIQUE` (`device_id` ASC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 创建conversation_histories表 -CREATE TABLE IF NOT EXISTS `conversation_histories` ( - `id` INT NOT NULL AUTO_INCREMENT, - `device_id` VARCHAR(64) NOT NULL, - `role_key` VARCHAR(64) NOT NULL, - `last_interaction_time` FLOAT NOT NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - INDEX `idx_device_id` (`device_id` ASC), - INDEX `idx_role_key` (`role_key` ASC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 创建conversation_messages表 -CREATE TABLE IF NOT EXISTS `conversation_messages` ( - `id` INT NOT NULL AUTO_INCREMENT, - `conversation_id` INT NOT NULL, - `is_user` TINYINT(1) NOT NULL DEFAULT 0, - `content` TEXT NOT NULL, - `timestamp` FLOAT NOT NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - INDEX `idx_conversation_id` (`conversation_id` ASC), - CONSTRAINT `fk_messages_conversation` - FOREIGN KEY (`conversation_id`) - REFERENCES `conversation_histories` (`id`) - ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 创建roles表 -CREATE TABLE IF NOT EXISTS `roles` ( - `id` INT NOT NULL AUTO_INCREMENT, - `role_key` VARCHAR(64) NOT NULL, - `name` VARCHAR(128) NOT NULL, - `description` TEXT NULL, - `content` TEXT NOT NULL, - `default_language` VARCHAR(10) NULL, - `asr_provider` VARCHAR(64) NULL, - `llm_provider` VARCHAR(64) NULL, - `tts_provider` VARCHAR(64) NULL, - `competitive_llm_mode` TINYINT(1) NULL, - `volcano_model_id` VARCHAR(64) NULL, - `volcano_voice_type` VARCHAR(64) NULL, - `tencent_voice_type` VARCHAR(64) NULL, - `aliyun_voice_name` VARCHAR(64) NULL, - `minimax_voice_id` VARCHAR(64) NULL, - `url` VARCHAR(255) NULL, - `homophones` JSON NULL, - `enabled` TINYINT(1) NOT NULL DEFAULT 1, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE INDEX `role_key_UNIQUE` (`role_key` ASC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 创建role_languages表 -CREATE TABLE IF NOT EXISTS `role_languages` ( - `id` INT NOT NULL AUTO_INCREMENT, - `role_id` INT NOT NULL, - `language_code` VARCHAR(10) NOT NULL, - `name` VARCHAR(128) NULL, - `content` TEXT NULL, - `asr_provider` VARCHAR(64) NULL, - `llm_provider` VARCHAR(64) NULL, - `tts_provider` VARCHAR(64) NULL, - `volcano_voice_type` VARCHAR(64) NULL, - `tencent_voice_type` VARCHAR(64) NULL, - `aliyun_voice_name` VARCHAR(64) NULL, - `minimax_voice_id` VARCHAR(64) NULL, - `url` VARCHAR(255) NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE INDEX `uix_role_language` (`role_id`, `language_code`), - CONSTRAINT `fk_role_languages_role` - FOREIGN KEY (`role_id`) - REFERENCES `roles` (`id`) - ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 创建device_auth表 -CREATE TABLE IF NOT EXISTS `device_auth` ( - `id` INT NOT NULL AUTO_INCREMENT, - `device_id` VARCHAR(64) NOT NULL, - `serial_number` VARCHAR(64) NOT NULL, - `batch_id` VARCHAR(20) NULL, - `is_active` TINYINT(1) NOT NULL DEFAULT 1, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE INDEX `device_id_UNIQUE` (`device_id` ASC), - INDEX `idx_batch_id` (`batch_id` ASC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 创建device_firmware_update表 -CREATE TABLE IF NOT EXISTS `device_firmware_update` ( - `id` INT NOT NULL AUTO_INCREMENT, - `device_id` VARCHAR(64) NOT NULL, - `serial_number` VARCHAR(64) NOT NULL, - `mac_address` VARCHAR(512) NULL, - `firmware_version` VARCHAR(64) NOT NULL, - `update_status` VARCHAR(32) NOT NULL DEFAULT 'success', - `progress` FLOAT NULL DEFAULT 0.0, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE INDEX `device_id_UNIQUE` (`device_id` ASC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 创建system_config表 -CREATE TABLE IF NOT EXISTS `system_config` ( - `id` INT NOT NULL AUTO_INCREMENT, - `config_key` VARCHAR(128) NOT NULL, - `config_value` TEXT NULL, - `description` VARCHAR(255) NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE INDEX `config_key_UNIQUE` (`config_key` ASC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 添加初始角色配置 -INSERT INTO `device_configs` (`device_id`, `selected_role_key`, `preferred_language`, `last_update_time`) -VALUES ('default', 'assistant', 'zh', UNIX_TIMESTAMP()) -ON DUPLICATE KEY UPDATE `selected_role_key`=`selected_role_key`; - --- 添加默认系统配置 -INSERT INTO `system_config` (`config_key`, `config_value`, `description`) -VALUES -('latest_firmware_version', '1.0.0', '最新固件版本'), -('update_firmware_url', 'https://example.com/firmware/latest.bin', '固件更新URL') -ON DUPLICATE KEY UPDATE `config_value`=`config_value`; - --- 添加默认角色 -INSERT INTO `roles` (`role_key`, `name`, `description`, `content`, `default_language`, `enabled`) -VALUES ( - 'assistant', - '智能助手', - '默认智能助手角色', - '你是一个友好的智能助手,乐于帮助用户解答问题。', - 'zh', - 1 -) -ON DUPLICATE KEY UPDATE `role_key`=`role_key`; \ No newline at end of file diff --git a/mini-program/database/sql/004_device_bindings_owner_user_migration.sql b/mini-program/database/sql/004_device_bindings_owner_user_migration.sql deleted file mode 100644 index 47ead87..0000000 --- a/mini-program/database/sql/004_device_bindings_owner_user_migration.sql +++ /dev/null @@ -1,46 +0,0 @@ --- Migration: support "bind device first, then set child profile" --- Target: MySQL 8.x - --- 1) Add owner_user_id on device_bindings. -ALTER TABLE device_bindings - ADD COLUMN owner_user_id BIGINT NULL AFTER device_id; - --- 2) Backfill owner_user_id from active parent-child relation (prefer primary relation). -UPDATE device_bindings b -SET owner_user_id = ( - SELECT r.user_id - FROM parent_child_relations r - WHERE r.child_id = b.child_id - AND r.status = 1 - ORDER BY r.is_primary DESC, r.id ASC - LIMIT 1 -) -WHERE b.owner_user_id IS NULL; - --- 3) Fallback backfill from latest bind history. -UPDATE device_bindings b -SET owner_user_id = ( - SELECT h.bound_by_user_id - FROM device_bind_history h - WHERE h.device_id = b.device_id - ORDER BY h.bound_at DESC, h.id DESC - LIMIT 1 -) -WHERE b.owner_user_id IS NULL; - --- 4) Ensure no NULL owner before setting NOT NULL. --- If this query returns rows, handle manually before continuing: --- SELECT id, device_id, child_id FROM device_bindings WHERE owner_user_id IS NULL; - --- 5) Make schema changes for new flow. -ALTER TABLE device_bindings - MODIFY COLUMN owner_user_id BIGINT NOT NULL; - -ALTER TABLE device_bindings - MODIFY COLUMN child_id BIGINT NULL; - -ALTER TABLE device_bindings - ADD INDEX idx_device_bindings_owner_user_id (owner_user_id); - -ALTER TABLE device_bind_history - MODIFY COLUMN child_id BIGINT NULL; diff --git a/mini-program/design.md b/mini-program/design.md index 4570147..c4db5b0 100644 --- a/mini-program/design.md +++ b/mini-program/design.md @@ -1,4 +1,4 @@ -# Banban 统一数据与接口设计文档(Mini Program + talkingq-url,V3) +# Banban 统一数据与接口设计文档(Mini Program + talkingq-url,V4) ## 1. 目标与边界 @@ -7,12 +7,14 @@ 1. 两个服务使用同一套设备身份(`device_id`)。 2. 保留 `talkingq-url/mysql/init/01-init.sql` 现有表结构不变。 3. 在不改动基础表的前提下,补齐家长/小孩/绑定/聊天/定位能力。 +4. 家长账号体系统一采用微信小程序登录,服务端基于 `code2Session` 换取真实 `openid/unionid`。 硬性边界: 1. `01-init.sql` 中现有表不改表名、不改字段、不改约束。 2. 所有新增表仅做增量扩展。 3. 新增表字段命名风格对齐现有风格:`snake_case`、`*_id`、`is_active`、`status`、`created_at`、`updated_at`。 +4. 微信 `AppSecret` 只能保存在服务端配置中,小程序端只持有一次性 `code`。 ## 2. 现有冻结表(来自 01-init.sql) @@ -42,10 +44,20 @@ 3. `unionid` VARCHAR(64) NULL 4. `nickname` VARCHAR(64) NULL 5. `avatar_url` VARCHAR(255) NULL -6. `phone` VARCHAR(20) NULL -7. `status` TINYINT NOT NULL DEFAULT 1 -8. `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -9. `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +6. `avatar_file_key` VARCHAR(255) NULL +7. `phone` VARCHAR(20) NULL +8. `status` TINYINT NOT NULL DEFAULT 1 +9. `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +10. `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + +说明: + +1. `avatar_file_key` 作为头像主存储值,保存 COS 对象 key。 +2. `avatar_url` 作为兼容或展示字段使用,不作为唯一主依据。 +3. `openid` 必须存储微信服务端 `code2Session` 返回的真实 OpenID;禁止将前端 `code`、测试用户名或设备标识写入该字段。 +4. `unionid` 仅在微信返回时写入;历史记录为空时,后续登录可补写,不作为首次登录成功前置条件。 +5. `nickname/avatar_url` 为展示资料,可由小程序端在登录时上传并在后续更新,但不参与身份认证。 +6. `phone` 为业务补充字段,与微信登录态解耦;如后续接入手机号授权,应基于服务端掌握的 `session_key` 解密后再写入。 索引建议: @@ -90,25 +102,33 @@ ### 3.4 设备归属表 `device_bindings` -用途:表达“当前哪块设备绑定到哪个小孩”,并与 `device_auth` 对齐。 +用途:表达“当前哪块设备归属到哪个家长,并可选关联到哪个小孩”,并与 `device_auth` 对齐。 建议字段: 1. `id` BIGINT UNSIGNED PK AUTO_INCREMENT 2. `device_id` VARCHAR(64) NOT NULL -3. `child_id` BIGINT UNSIGNED NOT NULL -4. `status` TINYINT NOT NULL DEFAULT 1 -5. `bound_at` DATETIME NOT NULL -6. `unbound_at` DATETIME NULL -7. `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -8. `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +3. `owner_user_id` BIGINT UNSIGNED NOT NULL +4. `child_id` BIGINT UNSIGNED NULL +5. `status` TINYINT NOT NULL DEFAULT 1 +6. `bound_at` DATETIME NOT NULL +7. `unbound_at` DATETIME NULL +8. `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +9. `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + +说明: + +1. 设备可以先归属到家长,再在后续流程中补充关联 `child_id`。 +2. `child_id` 为空表示当前设备已归属,但尚未绑定到具体小孩。 约束建议: 1. `UNIQUE(device_id)` 2. `UNIQUE(child_id)` -3. `FOREIGN KEY (device_id) REFERENCES device_auth(device_id)` -4. `FOREIGN KEY (child_id) REFERENCES children(child_id)` +3. `INDEX idx_owner_user_id(owner_user_id)` +4. `FOREIGN KEY (device_id) REFERENCES device_auth(device_id)` +5. `FOREIGN KEY (owner_user_id) REFERENCES parents(user_id)` +6. `FOREIGN KEY (child_id) REFERENCES children(child_id)` ### 3.5 绑定会话表 `device_bind_sessions` @@ -318,13 +338,24 @@ ## 5. 核心业务流程 -### 5.1 设备注册与鉴权 +### 5.1 家长登录流程(微信小程序) + +1. 小程序调用 `wx.login` / `Taro.login` 获取一次性 `code`。 +2. 小程序调用后端 `POST /auth/login`,上传 `code`,并可附带 `nickname/avatar_url` 作为资料字段。 +3. 后端使用服务端配置的 `wechat_app_id + wechat_app_secret` 调用微信 `code2Session`。 +4. 后端从微信响应中获取真实 `openid`、可选 `unionid`,以及仅供服务端使用的 `session_key`。 +5. 后端以 `openid` 为唯一身份键 upsert `parents`;若本次拿到 `unionid` 且库中为空,则补写 `unionid`。 +6. 后端签发本地 `access_token(JWT)` 返回给小程序,后续业务接口仍统一使用本地 Bearer Token。 +7. 若微信换码失败、`openid` 缺失、`code` 失效或被重复使用,登录直接失败,不创建本地用户。 +8. `session_key` 仅用于后续需要的微信敏感数据解密能力(如手机号);当前阶段不作为登录态主键,也不写入 `parents` 主表。 + +### 5.2 设备注册与鉴权 1. 生产或后台调用 `talkingq-url` 现有注册接口,写入 `device_auth`。 2. 手表请求携带 `X-Device-ID` 与 `X-Device-Serial`。 3. 后端基于 `device_auth` 鉴权通过后继续处理聊天/定位/绑定。 -### 5.2 绑定流程(扫码 + 确认码) +### 5.3 绑定流程(扫码 + 确认码) 1. 家长登录后发起 `bind/start`,创建 `device_bind_sessions`。 2. 手表上报确认码,更新会话挑战信息。 @@ -335,16 +366,17 @@ 7. 追加 `device_bind_history`。 8. 更新会话状态为 `confirmed/consumed` 并提交事务。 -### 5.3 聊天流程 +### 5.4 聊天流程 1. 设备 AI 对话仍走 `conversation_histories + conversation_messages`。 2. 家长-小孩、小孩-小孩消息走 `im_conversations + im_messages`。 3. IM 消息统一使用 `sender_type/sender_id + receiver_type/receiver_id`,不按场景拆专用发送者字段。 -4. 发送接口必须使用 `client_msg_id` 幂等。 -5. 查询前必须校验 `parent_child_relations` 权限。 -6. 列表展示优先读取消息快照字段,降低跨表查询成本。 +4. `mini-program` 侧不再新增独立的 `chat_*` 消息表定义,统一以 `im_*` 为 IM 正表。 +5. 发送接口必须使用 `client_msg_id` 幂等。 +6. 查询前必须校验 `parent_child_relations` 权限。 +7. 列表展示优先读取消息快照字段,降低跨表查询成本。 -### 5.4 位置流程 +### 5.5 位置流程 1. 手表上报定位写入 `child_location_history`。 2. 同事务更新 `child_location_current`。 @@ -361,19 +393,91 @@ 6. 关键写路径(绑定确认、发消息、位置写入)必须事务化。 7. 腾讯位置服务启用前提:必须配置合法 `key`,并完成域名/来源白名单与配额设置。 8. 地图相关接口统一使用 `gcj02` 和 `lat,lng`;禁止混用 `wgs84/bd09` 直接入库。 +9. 小程序端只上送一次性 `code`,不得自行调用微信换取 `openid`,更不得持有 `AppSecret`。 +10. `code` 只用于调用微信 `code2Session`,不得入库为 `openid`、`unionid` 或任何长期身份字段。 +11. `session_key` 不返回前端,不写入 `parents` 主表;如需支持手机号等敏感数据解密,应服务端短期缓存并设置明确过期时间。 +12. `nickname/avatar_url` 仅作资料字段,不能作为登录态、账号归并或权限判断依据。 ## 7. 分阶段落地 -1. 第一阶段:落地 `parents/children/parent_child_relations/device_bind_*`,打通绑定链路。 -2. 第二阶段:落地 `im_conversations/im_messages`,打通家长与小孩 IM。 -3. 第三阶段:落地 `child_location_current/history`,打通定位链路。 -4. 第四阶段:补充审计、告警、实时推送和运营能力。 +1. 第一阶段:落地微信登录链路,打通 `Taro.login -> /auth/login -> code2Session -> parents -> JWT`。 +2. 第二阶段:落地 `parents/children/parent_child_relations/device_bind_*`,打通绑定链路。 +3. 第三阶段:落地 `im_conversations/im_messages`,打通家长与小孩 IM。 +4. 第四阶段:落地 `child_location_current/history`,打通定位链路。 +5. 第五阶段:补充审计、告警、实时推送和运营能力。 ## 8. 验收标准 -1. 不修改 `01-init.sql` 任一现有表,服务可正常启动。 -2. 设备鉴权、角色配置、OTA能力保持可用。 -3. 家长可完成扫码绑定、解绑、重绑,且历史保留。 -4. 家长可查看自己小孩与其他小孩完整双向消息。 -5. 小程序可查看小孩最新位置与历史轨迹。 +1. 家长可通过真实微信登录完成换码,并获取本地 Bearer Token。 +2. `parents.openid` 存储值来自微信返回的真实 OpenID,而不是前端 `code` 或测试标识。 +3. 不修改 `01-init.sql` 任一现有表,服务可正常启动。 +4. 设备鉴权、角色配置、OTA能力保持可用。 +5. 家长可完成扫码绑定、解绑、重绑,且历史保留。 +6. 家长可查看自己小孩与其他小孩完整双向消息。 +7. 小程序可查看小孩最新位置与历史轨迹。 + +## 9. 共享库落地规范 + +### 9.1 唯一数据库与唯一 Schema + +1. `mini-program` 与 `talkingq-url` 必须共用同一个 MySQL 数据库:`talkingq`。 +2. `database/talkingq_shared_schema.sql` 是唯一 schema 真相源。 +3. `mini-program/database/init.sql` 仅保留给历史上的“单服务独立开发”场景,不再作为共享部署的建库依据。 + +### 9.2 表归属(Table Ownership) + +`talkingq-url` 拥有以下表的写入责任: + +1. `device_auth` +2. `device_configs` +3. `conversation_histories` +4. `conversation_messages` +5. `roles` +6. `role_languages` +7. `device_firmware_update` +8. `system_config` + +`mini-program` 拥有以下表的写入责任: + +1. `parents` +2. `children` +3. `parent_child_relations` +4. `device_bindings` +5. `device_bind_sessions` +6. `device_bind_history` +7. `device_settings` +8. `cards` +9. `im_conversations` +10. `im_messages` +11. `child_location_current` +12. `child_location_history` + +约束: + +1. 两个服务允许跨服务读取共享依赖表,但不允许跨服务写对方负责的主表。 +2. `mini-program` 只读 `device_auth`,不直接承担设备注册主流程。 +3. `talkingq-url` 不写 `parents/children/device_bindings/im_* /child_location_*`。 + +### 9.3 共享字段与真相源 + +1. 设备主身份统一以 `device_auth.device_id` 为准。 +2. 设备与 AI 的历史消息统一以 `conversation_histories + conversation_messages` 为准。 +3. 家长/儿童 IM 统一以 `im_conversations + im_messages` 为准。 +4. 设备音量如需双端共享,以 `device_configs.volume` 为真相源;`device_settings.volume` 仅作小程序展示和兼容字段。 + +### 9.4 迁移原则 + +1. 目标库固定为 `talkingq`。 +2. 迁移时以 `talkingq-url` 当前使用的 `talkingq` 数据为准。 +3. 历史 `mini_program` 库中的业务数据不并入 `talkingq`,也不作为冲突解决依据。 +4. 迁移动作只做两件事: + 1. 在 `talkingq` 上补齐 `mini-program` 所需扩展表。 + 2. 将 `mini-program` 服务配置切换到 `talkingq`。 +5. `mini_program` 库可在切换验证完成后归档或删除,但不再作为运行时数据库。 + +### 9.5 启动与建表策略 + +1. 共享部署下,服务启动默认只检查数据库连通性,不自动执行 ORM `create_all`。 +2. 只有显式开启开发开关时,`mini-program` 才允许自动建表。 +3. 生产、联调、共享测试环境统一通过 `database/talkingq_shared_schema.sql` 或正式 migration 执行建表与变更。 diff --git a/mini-program/docs/api.md b/mini-program/docs/api.md index 9ae5f15..d257007 100644 --- a/mini-program/docs/api.md +++ b/mini-program/docs/api.md @@ -38,18 +38,25 @@ http://127.0.0.1:8001 用途: -- 用户登录并获取访问令牌(access token)。 -- 当前示例账号:`test1/test2/test3`,密码均为 `123`。 +- 小程序端上传 `wx.login` 返回的一次性 `code`。 +- 服务端调用微信 `code2Session` 换取真实 `openid/unionid`,并签发本地访问令牌。 请求体: ```json { - "username": "test1", - "password": "123" + "code": "0312abcdEFGHijklMNopqrstUVwxYZ12", + "nickname": "家长昵称", + "avatar_url": "https://thirdwx.qlogo.cn/mmopen/xxx/132" } ``` +字段说明: + +- `code`:必填,来自微信 `wx.login` / `Taro.login` +- `nickname`:可选,仅作为资料字段 +- `avatar_url`:可选,仅作为资料字段 + 成功响应(`200`): ```json @@ -63,8 +70,10 @@ http://127.0.0.1:8001 常见错误码: -- `401`:用户名或密码错误,或账号不可用 +- `401`:微信 `code` 无效、已过期或已被使用 - `422`:请求参数校验失败 +- `502`:微信登录服务不可用或返回异常数据 +- `503`:服务端未配置正确的微信小程序凭证 ## 3. 发送消息 @@ -218,3 +227,139 @@ GET /messages?conversation_id=1&cursor_seq=50&limit=20 - `403`:无权限读取该会话 - `404`:会话不存在 - `422`:请求参数校验失败 + +## 5. 家长头像 + +### `POST /parents/me/avatar` + +用途: + +- 上传当前登录用户头像到 COS 头像桶。 +- 服务端会将 COS `avatar_file_key` 写入数据库,并在响应中返回可用的 `avatar_url`。 + +请求头: + +``` +Authorization: Bearer +Content-Type: multipart/form-data +``` + +表单字段: + +- `file`:头像图片文件,支持 `jpg/jpeg/png/webp` + +成功响应(`200`): + +```json +{ + "user_id": 1, + "openid": "wx_xxx", + "unionid": null, + "nickname": "Parent A", + "avatar_url": "https://ava-1320289366.cos.ap-guangzhou.myqcloud.com/...", + "phone": null, + "status": 1 +} +``` + +常见错误码: + +- `401`:未登录或 token 无效 +- `404`:当前用户不存在 +- `413`:头像文件过大 +- `415`:头像文件类型不支持 + +### `GET /parents/{user_id}/avatar` + +用途: + +- 获取指定用户头像的可访问 URL。 +- 如果数据库里存的是 COS `avatar_file_key`,服务端会返回短时有效的签名 URL。 +- 如果数据库里仍是旧的 `avatar_url`,服务端会直接返回旧 URL。 + +请求头: + +``` +Authorization: Bearer +``` + +成功响应(`200`): + +```json +{ + "avatar_url": "https://ava-1320289366.cos.ap-guangzhou.myqcloud.com/...", + "expires_in": 86400 +} +``` + +常见错误码: + +- `401`:未登录或 token 无效 +- `404`:用户不存在或未设置头像 + +## 6. 设备绑定列表 + +### `GET /bindings` + +用途: + +- 查询当前登录家长名下的全部有效绑定设备。 +- 返回结果包含 `child_name`,可直接用于首页设备卡片展示。 +- 返回结果按最新绑定记录优先排序。 + +请求头: + +``` +Authorization: Bearer +``` + +Query 参数: + +- `cursor`(可选):`int >= 1`,用于向后翻页 +- `limit`(可选):默认 `20`,最大 `100` + +请求示例: + +``` +GET /bindings +GET /bindings?cursor=120&limit=20 +``` + +成功响应(`200`): + +```json +{ + "items": [ + { + "device_id": "DEV-002", + "child_id": null, + "child_name": null, + "status": 1, + "bound_at": "2026-04-16T18:00:00" + }, + { + "device_id": "DEV-001", + "child_id": 10, + "child_name": "小明", + "status": 1, + "bound_at": "2026-04-16T17:30:00" + } + ], + "total": 2, + "next_cursor": null +} +``` + +字段说明: + +- `device_id`:设备业务标识 +- `child_id`:当前绑定儿童 ID,可为空 +- `child_name`:当前绑定儿童昵称,可为空 +- `status`:绑定状态,当前列表只返回有效绑定 +- `bound_at`:设备绑定时间 +- `next_cursor`:下一页游标,没有更多数据时为 `null` + +常见错误码: + +- `401`:未登录或 token 无效 +- `422`:请求参数校验失败 diff --git a/mini-program/requirements.txt b/mini-program/requirements.txt index 48b1496..9233ce8 100644 --- a/mini-program/requirements.txt +++ b/mini-program/requirements.txt @@ -2,9 +2,11 @@ fastapi==0.135.3 uvicorn[standard]==0.44.0 pydantic-settings==2.8.1 python-dotenv==1.0.1 +python-multipart==0.0.26 SQLAlchemy==2.0.38 PyMySQL==1.1.1 PyJWT==2.10.1 httpx==0.28.1 +cos-python-sdk-v5==1.9.41 pytest==8.3.4 pytest-asyncio==0.24.0