Compare commits

...

7 Commits

Author SHA1 Message Date
stu2not
e7fe92b863 更新小程序后端 API 文档 2026-04-16 18:21:54 +08:00
stu2not
9db35d6284 整理小程序数据库初始化脚本 2026-04-16 18:21:46 +08:00
stu2not
fb78863ee6 小程序后端增强绑定能力并支持多设备列表 2026-04-16 18:20:56 +08:00
stu2not
0683de528a 小程序后端重构消息接口到 IM 表结构 2026-04-16 18:19:48 +08:00
stu2not
9d7c1ce7b4 小程序后端补充数据库兼容层与启动适配 2026-04-16 18:19:28 +08:00
stu2not
2526365af1 小程序后端接入微信登录与家长头像能力 2026-04-16 18:19:19 +08:00
stu2not
db888df50f 小程序前端接入微信登录与环境配置 2026-04-16 18:18:48 +08:00
30 changed files with 1270 additions and 904 deletions

View File

@@ -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: {}
}
}

View File

@@ -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)
}

View File

@@ -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: {}
}
}

View 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

View File

@@ -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: '登录失败,请重试',

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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
)
)

View File

@@ -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"])

View 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")

View File

@@ -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

View File

@@ -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"))

View File

@@ -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
"""
),

View File

@@ -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,

View File

@@ -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"

View File

@@ -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)

View File

@@ -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"}

View 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}"
)

View File

@@ -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}

View File

@@ -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

View 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()

View File

@@ -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",

View File

@@ -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);

View File

@@ -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'
);

View File

@@ -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-1000为关闭',
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`;

View File

@@ -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;

View File

@@ -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`:请求参数校验失败

View File

@@ -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