Compare commits
7 Commits
1b4c47f77a
...
e7fe92b863
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7fe92b863 | ||
|
|
9db35d6284 | ||
|
|
fb78863ee6 | ||
|
|
0683de528a | ||
|
|
9d7c1ce7b4 | ||
|
|
2526365af1 | ||
|
|
db888df50f |
@@ -3,7 +3,10 @@ module.exports = {
|
||||
NODE_ENV: '"development"'
|
||||
},
|
||||
defineConstants: {
|
||||
__APP_ENV__: '"development"',
|
||||
// Local backend for daily development.
|
||||
__API_BASE_URL__: '"http://192.168.101.78:8001"'
|
||||
},
|
||||
mini: {},
|
||||
h5: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require('path')
|
||||
|
||||
module.exports = {
|
||||
const config = {
|
||||
projectName: 'banban-mini',
|
||||
date: '2025-3-3',
|
||||
designWidth: 750,
|
||||
@@ -59,4 +59,13 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function (merge) {
|
||||
const envConfig =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? require('./dev')
|
||||
: require('./prod')
|
||||
|
||||
return merge({}, config, envConfig)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ module.exports = {
|
||||
NODE_ENV: '"production"'
|
||||
},
|
||||
defineConstants: {
|
||||
__APP_ENV__: '"production"',
|
||||
// Replace with the production backend before publishing a release build.
|
||||
__API_BASE_URL__: '"http://192.168.101.78:8001"'
|
||||
},
|
||||
mini: {},
|
||||
h5: {}
|
||||
}
|
||||
}
|
||||
|
||||
14
banban-mini/src/config/env.ts
Normal file
14
banban-mini/src/config/env.ts
Normal file
@@ -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: __APP_ENV__,
|
||||
apiBaseUrl: __API_BASE_URL__,
|
||||
}
|
||||
|
||||
export const API_BASE_URL = APP_CONFIG.apiBaseUrl
|
||||
@@ -1,7 +1,7 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import { useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { BASE_URL, request } from '@/services/api'
|
||||
import { wechatLogin } from '@/services/auth'
|
||||
import './index.scss'
|
||||
|
||||
export default function Login() {
|
||||
@@ -25,6 +25,9 @@ export default function Login() {
|
||||
const loginRes = await Taro.login()
|
||||
const code = loginRes.code
|
||||
console.log('[Login] Got code:', code ? 'yes' : 'no')
|
||||
if (!code) {
|
||||
throw new Error('未获取到微信登录凭证')
|
||||
}
|
||||
|
||||
// 2. 获取用户头像信息 (可选)
|
||||
let userInfo = null
|
||||
@@ -41,48 +44,26 @@ export default function Login() {
|
||||
|
||||
// 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
|
||||
}
|
||||
const res = await wechatLogin({
|
||||
code,
|
||||
nickname: userInfo?.nickName,
|
||||
avatar_url: userInfo?.avatarUrl
|
||||
})
|
||||
console.log('[Login] Backend login success, user_id:', res.user_id)
|
||||
|
||||
// 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)
|
||||
|
||||
// 保存用户信息用于显示
|
||||
// 4. 保存用户信息用于显示
|
||||
console.log('[Login] Step 4: Saving user info...')
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
Taro.removeStorageSync('userInfo')
|
||||
}
|
||||
|
||||
Taro.hideLoading()
|
||||
console.log('[Login] Step 6: Login complete, showing success toast')
|
||||
console.log('[Login] Step 5: Login complete, showing success toast')
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
|
||||
// 6. 跳转到首页
|
||||
@@ -95,7 +76,7 @@ export default function Login() {
|
||||
Taro.hideLoading()
|
||||
console.error('[Login] Login failed:', err)
|
||||
|
||||
// 如果是 401 错误,尝试通过创建新用户登录
|
||||
// 401 通常表示微信 code 失效或已被使用,提示用户重试登录
|
||||
if (err.status === 401) {
|
||||
Taro.showToast({
|
||||
title: '登录失败,请重试',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { API_BASE_URL } from '@/config/env'
|
||||
|
||||
// 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
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
@@ -53,4 +53,4 @@ export function getToken(): string {
|
||||
|
||||
export function getCurrentUserId(): number {
|
||||
return Taro.getStorageSync('user_id') || 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { request, BASE_URL } from './api'
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
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
|
||||
@@ -11,22 +26,18 @@ export interface Parent {
|
||||
}
|
||||
|
||||
// 微信登录
|
||||
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 wechatLogin(data: WechatLoginPayload): Promise<LoginSession> {
|
||||
const session = await request<LoginSession>('/auth/login', {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
|
||||
Taro.setStorageSync('token', session.access_token)
|
||||
Taro.setStorageSync('token_type', session.token_type)
|
||||
Taro.setStorageSync('user_id', session.user_id)
|
||||
Taro.setStorageSync('expires_in', session.expires_in)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// 创建家长
|
||||
@@ -66,10 +77,12 @@ export function getToken(): string {
|
||||
// 清除 token
|
||||
export function clearToken(): void {
|
||||
Taro.removeStorageSync('token')
|
||||
Taro.removeStorageSync('token_type')
|
||||
Taro.removeStorageSync('user_id')
|
||||
Taro.removeStorageSync('expires_in')
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
export function getCurrentUserId(): number {
|
||||
return Taro.getStorageSync('user_id') || 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,19 @@ DB_USER=root
|
||||
DB_PASSWORD=change_me
|
||||
DB_NAME=mini_program
|
||||
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
|
||||
|
||||
@@ -252,6 +252,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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
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"])
|
||||
|
||||
41
mini-program/app/db_compat.py
Normal file
41
mini-program/app/db_compat.py
Normal file
@@ -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")
|
||||
@@ -1,35 +1,29 @@
|
||||
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, 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
|
||||
|
||||
|
||||
configure_logging()
|
||||
@@ -59,12 +53,9 @@ def create_app() -> FastAPI:
|
||||
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)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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:
|
||||
@@ -45,6 +45,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
|
||||
@@ -140,6 +154,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,
|
||||
|
||||
@@ -5,11 +5,18 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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,
|
||||
get_db_dialect_name,
|
||||
inserted_primary_key,
|
||||
select_for_update_clause,
|
||||
)
|
||||
from app.security import get_current_user_id
|
||||
from app.schemas.message import (
|
||||
MessageCreateRequest,
|
||||
@@ -20,6 +27,12 @@ try:
|
||||
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,
|
||||
get_db_dialect_name,
|
||||
inserted_primary_key,
|
||||
select_for_update_clause,
|
||||
)
|
||||
from security import get_current_user_id
|
||||
from schemas.message import (
|
||||
MessageCreateRequest,
|
||||
@@ -32,6 +45,9 @@ except ModuleNotFoundError:
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
logger = logging.getLogger("app.messages")
|
||||
|
||||
PARENT_PARTICIPANT_TYPE = 1
|
||||
PARENT_DIRECT_CONVERSATION_TYPE = 3
|
||||
|
||||
|
||||
def _build_preview(content_type: int, content_text: str | None) -> str:
|
||||
if content_type == 1:
|
||||
@@ -61,12 +77,16 @@ def _normalize_content_json(value: Any) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def _row_to_message_item(row: Mapping[str, Any]) -> MessageItem:
|
||||
sender_user_id = row.get("sender_user_id")
|
||||
if sender_user_id is not None:
|
||||
sender_user_id = int(sender_user_id)
|
||||
|
||||
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"]),
|
||||
sender_user_id=sender_user_id,
|
||||
role=int(row.get("role", 1)),
|
||||
content_type=int(row["content_type"]),
|
||||
content_text=row["content_text"],
|
||||
content_json=_normalize_content_json(row["content_json"]),
|
||||
@@ -88,8 +108,11 @@ def _get_existing_message(
|
||||
id,
|
||||
conversation_id,
|
||||
seq,
|
||||
sender_user_id,
|
||||
role,
|
||||
CASE
|
||||
WHEN sender_type = :parent_participant_type THEN sender_id
|
||||
ELSE NULL
|
||||
END AS sender_user_id,
|
||||
1 AS role,
|
||||
content_type,
|
||||
content_text,
|
||||
content_json,
|
||||
@@ -97,19 +120,170 @@ def _get_existing_message(
|
||||
media_duration_ms,
|
||||
client_msg_id,
|
||||
created_at
|
||||
FROM chat_message
|
||||
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},
|
||||
{
|
||||
"conversation_id": conversation_id,
|
||||
"client_msg_id": client_msg_id,
|
||||
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _build_parent_direct_pair(user_a_id: int, user_b_id: int) -> tuple[str, str, str]:
|
||||
low_id, high_id = sorted((user_a_id, user_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 _next_primary_key(db: Session, table_name: str) -> int:
|
||||
if table_name not in {"im_conversations", "im_messages"}:
|
||||
raise ValueError(f"unsupported table name: {table_name}")
|
||||
return int(
|
||||
db.execute(text(f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table_name}")).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _get_direct_conversation(
|
||||
db: Session,
|
||||
user_low_id: int,
|
||||
user_high_id: int,
|
||||
) -> Mapping[str, Any] | None:
|
||||
_, _, pair_key = _build_parent_direct_pair(user_low_id, user_high_id)
|
||||
return (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, last_seq, status
|
||||
FROM im_conversations
|
||||
WHERE conversation_type = :conversation_type
|
||||
AND pair_key = :pair_key
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"conversation_type": PARENT_DIRECT_CONVERSATION_TYPE,
|
||||
"pair_key": pair_key,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _get_or_create_direct_conversation(
|
||||
db: Session,
|
||||
user_low_id: int,
|
||||
user_high_id: int,
|
||||
) -> int:
|
||||
existing = _get_direct_conversation(
|
||||
db=db,
|
||||
user_low_id=user_low_id,
|
||||
user_high_id=user_high_id,
|
||||
)
|
||||
if existing:
|
||||
return int(existing["id"])
|
||||
|
||||
now_sql = current_timestamp_sql(db)
|
||||
participant_a_id, participant_b_id, pair_key = _build_parent_direct_pair(user_low_id, user_high_id)
|
||||
conversation_id = None
|
||||
if get_db_dialect_name(db) == "sqlite":
|
||||
conversation_id = _next_primary_key(db, "im_conversations")
|
||||
|
||||
try:
|
||||
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": PARENT_DIRECT_CONVERSATION_TYPE,
|
||||
"participant_a_type": PARENT_PARTICIPANT_TYPE,
|
||||
"participant_a_id": participant_a_id,
|
||||
"participant_b_type": PARENT_PARTICIPANT_TYPE,
|
||||
"participant_b_id": participant_b_id,
|
||||
"pair_key": pair_key,
|
||||
}
|
||||
if conversation_id is not None:
|
||||
params["id"] = conversation_id
|
||||
|
||||
result = db.execute(
|
||||
text(
|
||||
insert_sql
|
||||
),
|
||||
params,
|
||||
)
|
||||
if conversation_id is not None:
|
||||
return conversation_id
|
||||
return inserted_primary_key(result)
|
||||
except IntegrityError:
|
||||
existing = _get_direct_conversation(
|
||||
db=db,
|
||||
user_low_id=user_low_id,
|
||||
user_high_id=user_high_id,
|
||||
)
|
||||
if existing:
|
||||
return int(existing["id"])
|
||||
raise
|
||||
|
||||
|
||||
def _get_active_parent_profiles(
|
||||
db: Session,
|
||||
*,
|
||||
sender_user_id: int,
|
||||
peer_user_id: int,
|
||||
) -> dict[int, Mapping[str, Any]]:
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_id, nickname, avatar_url
|
||||
FROM parents
|
||||
WHERE user_id IN (:sender_user_id, :peer_user_id)
|
||||
AND status = 1
|
||||
"""
|
||||
),
|
||||
{"sender_user_id": sender_user_id, "peer_user_id": peer_user_id},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return {int(row["user_id"]): row for row in rows}
|
||||
|
||||
|
||||
def _assert_conversation_access(
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
@@ -119,8 +293,13 @@ def _assert_conversation_access(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, user_low_id, user_high_id
|
||||
FROM chat_conversation
|
||||
SELECT
|
||||
id,
|
||||
participant_a_type,
|
||||
participant_a_id,
|
||||
participant_b_type,
|
||||
participant_b_id
|
||||
FROM im_conversations
|
||||
WHERE id = :conversation_id
|
||||
LIMIT 1
|
||||
"""
|
||||
@@ -133,10 +312,18 @@ def _assert_conversation_access(
|
||||
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"]),
|
||||
):
|
||||
current_user_id_str = str(current_user_id)
|
||||
is_participant = (
|
||||
(
|
||||
int(conversation_row["participant_a_type"]) == PARENT_PARTICIPANT_TYPE
|
||||
and conversation_row["participant_a_id"] == current_user_id_str
|
||||
)
|
||||
or (
|
||||
int(conversation_row["participant_b_type"]) == PARENT_PARTICIPANT_TYPE
|
||||
and conversation_row["participant_b_id"] == current_user_id_str
|
||||
)
|
||||
)
|
||||
if not is_participant:
|
||||
raise HTTPException(status_code=403, detail="no permission for this conversation")
|
||||
|
||||
|
||||
@@ -155,62 +342,30 @@ def create_message(
|
||||
|
||||
user_low_id = min(sender_user_id, peer_user_id)
|
||||
user_high_id = max(sender_user_id, peer_user_id)
|
||||
now_sql = current_timestamp_sql(db)
|
||||
|
||||
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:
|
||||
parents = _get_active_parent_profiles(
|
||||
db,
|
||||
sender_user_id=sender_user_id,
|
||||
peer_user_id=peer_user_id,
|
||||
)
|
||||
if len(parents) != 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 = _get_or_create_direct_conversation(
|
||||
db=db,
|
||||
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(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, last_seq, status
|
||||
FROM chat_conversation
|
||||
WHERE id = :conversation_id
|
||||
FOR UPDATE
|
||||
FROM im_conversations
|
||||
WHERE id = :conversation_id{select_for_update_clause(db)}
|
||||
"""
|
||||
),
|
||||
{"conversation_id": conversation_id},
|
||||
@@ -247,63 +402,92 @@ def create_message(
|
||||
|
||||
next_seq = int(conversation["last_seq"]) + 1
|
||||
preview = _build_preview(payload.content_type, payload.content_text)
|
||||
message_id = None
|
||||
if get_db_dialect_name(db) == "sqlite":
|
||||
message_id = _next_primary_key(db, "im_messages")
|
||||
|
||||
insert_result = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO chat_message (
|
||||
insert_sql = f"""
|
||||
INSERT INTO im_messages (
|
||||
{'id,' if message_id is not None else ''}
|
||||
conversation_id,
|
||||
seq,
|
||||
sender_user_id,
|
||||
role,
|
||||
sender_type,
|
||||
sender_id,
|
||||
receiver_type,
|
||||
receiver_id,
|
||||
content_type,
|
||||
content_text,
|
||||
content_json,
|
||||
media_file_key,
|
||||
media_duration_ms,
|
||||
client_msg_id,
|
||||
sender_name_snapshot,
|
||||
sender_avatar_snapshot,
|
||||
receiver_name_snapshot,
|
||||
receiver_avatar_snapshot,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
{':id,' if message_id is not None else ''}
|
||||
:conversation_id,
|
||||
:seq,
|
||||
:sender_user_id,
|
||||
1,
|
||||
:sender_type,
|
||||
:sender_id,
|
||||
:receiver_type,
|
||||
:receiver_id,
|
||||
:content_type,
|
||||
:content_text,
|
||||
:content_json,
|
||||
:media_file_key,
|
||||
:media_duration_ms,
|
||||
:client_msg_id,
|
||||
CURRENT_TIMESTAMP(3)
|
||||
:sender_name_snapshot,
|
||||
:sender_avatar_snapshot,
|
||||
:receiver_name_snapshot,
|
||||
:receiver_avatar_snapshot,
|
||||
{now_sql}
|
||||
)
|
||||
"""
|
||||
insert_params = {
|
||||
"conversation_id": conversation_id,
|
||||
"seq": next_seq,
|
||||
"sender_type": PARENT_PARTICIPANT_TYPE,
|
||||
"sender_id": str(sender_user_id),
|
||||
"receiver_type": PARENT_PARTICIPANT_TYPE,
|
||||
"receiver_id": str(peer_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,
|
||||
"sender_name_snapshot": parents[sender_user_id]["nickname"],
|
||||
"sender_avatar_snapshot": parents[sender_user_id]["avatar_url"],
|
||||
"receiver_name_snapshot": parents[peer_user_id]["nickname"],
|
||||
"receiver_avatar_snapshot": parents[peer_user_id]["avatar_url"],
|
||||
}
|
||||
if message_id is not None:
|
||||
insert_params["id"] = message_id
|
||||
|
||||
insert_result = db.execute(
|
||||
text(
|
||||
insert_sql
|
||||
),
|
||||
{
|
||||
"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,
|
||||
},
|
||||
insert_params,
|
||||
)
|
||||
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE chat_conversation
|
||||
f"""
|
||||
UPDATE im_conversations
|
||||
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)
|
||||
last_message_at = {now_sql},
|
||||
updated_at = {now_sql}
|
||||
WHERE id = :conversation_id
|
||||
"""
|
||||
),
|
||||
@@ -322,8 +506,11 @@ def create_message(
|
||||
id,
|
||||
conversation_id,
|
||||
seq,
|
||||
sender_user_id,
|
||||
role,
|
||||
CASE
|
||||
WHEN sender_type = :parent_participant_type THEN sender_id
|
||||
ELSE NULL
|
||||
END AS sender_user_id,
|
||||
1 AS role,
|
||||
content_type,
|
||||
content_text,
|
||||
content_json,
|
||||
@@ -331,12 +518,15 @@ def create_message(
|
||||
media_duration_ms,
|
||||
client_msg_id,
|
||||
created_at
|
||||
FROM chat_message
|
||||
FROM im_messages
|
||||
WHERE id = :message_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"message_id": insert_result.lastrowid},
|
||||
{
|
||||
"message_id": message_id if message_id is not None else inserted_primary_key(insert_result),
|
||||
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
@@ -383,8 +573,11 @@ def list_messages(
|
||||
id,
|
||||
conversation_id,
|
||||
seq,
|
||||
sender_user_id,
|
||||
role,
|
||||
CASE
|
||||
WHEN sender_type = :parent_participant_type THEN sender_id
|
||||
ELSE NULL
|
||||
END AS sender_user_id,
|
||||
1 AS role,
|
||||
content_type,
|
||||
content_text,
|
||||
content_json,
|
||||
@@ -392,13 +585,14 @@ def list_messages(
|
||||
media_duration_ms,
|
||||
client_msg_id,
|
||||
created_at
|
||||
FROM chat_message
|
||||
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,
|
||||
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
|
||||
}
|
||||
if cursor_seq is not None:
|
||||
sql += " AND seq < :cursor_seq"
|
||||
|
||||
@@ -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)
|
||||
return ParentResponse(**parent)
|
||||
|
||||
@@ -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"}
|
||||
return {"message": "logged out"}
|
||||
|
||||
134
mini-program/app/service/avatar_storage.py
Normal file
134
mini-program/app/service/avatar_storage.py
Normal file
@@ -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}"
|
||||
)
|
||||
@@ -31,6 +31,12 @@ class BindingService:
|
||||
def get_current_binding(self, user_id: int) -> Optional[Mapping]:
|
||||
return self.dao.get_current_by_user(user_id)
|
||||
|
||||
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, child_id: int | None, user_id: int) -> Mapping:
|
||||
self.dao.direct_bind(device_id, child_id, user_id)
|
||||
return {"device_id": device_id, "child_id": child_id}
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
125
mini-program/app/service/wechat_login.py
Normal file
125
mini-program/app/service/wechat_login.py
Normal file
@@ -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()
|
||||
@@ -29,6 +29,31 @@ class Settings(BaseSettings):
|
||||
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
|
||||
db_name: str = Field(default="mini_program", validation_alias="DB_NAME")
|
||||
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",
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
-- New tables per design.md
|
||||
-- Supports both MySQL and SQLite
|
||||
-- Current MySQL schema for mini-program
|
||||
|
||||
-- parents table
|
||||
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 +32,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 +44,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 +61,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 +84,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 +98,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 +110,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 +125,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 +144,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 +176,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 +199,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 +218,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);
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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`;
|
||||
@@ -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;
|
||||
@@ -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 <access_token>
|
||||
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 <access_token>
|
||||
```
|
||||
|
||||
成功响应(`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 <access_token>
|
||||
```
|
||||
|
||||
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`:请求参数校验失败
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user