新增小程序后端代码,包括数据库、路由、服务层等。

小程序前后端打通,包括登录、注册、绑定设备、查询绑定信息,修改小朋友名称等功能。
This commit is contained in:
HycJack
2026-04-13 01:54:57 +08:00
parent 33715373b0
commit a22fcafea9
46 changed files with 3713 additions and 268 deletions

BIN
mini-program/app/.DS_Store vendored Normal file

Binary file not shown.

BIN
mini-program/app/dao/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,13 @@
from sqlalchemy import text
from sqlalchemy.orm import Session
class BaseDAO:
def __init__(self, db: Session):
self.db = db
def execute(self, query, params: dict = None):
return self.db.execute(text(query), params or {})
def commit(self):
self.db.commit()

View File

@@ -0,0 +1,226 @@
import logging
import uuid
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import text
from app.dao import BaseDAO
logger = logging.getLogger("app.dao.binding")
class BindingDAO(BaseDAO):
def start_bind(self, user_id: int, device_id: str, child_id: int) -> str:
bind_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(minutes=10)
self.db.execute(
text(
"""
INSERT INTO device_bind_sessions (bind_token, device_id, initiator_user_id, target_child_id, expires_at, status)
VALUES (:bind_token, :device_id, :initiator_user_id, :target_child_id, :expires_at, 1)
"""
),
{
"bind_token": bind_token,
"device_id": device_id,
"initiator_user_id": user_id,
"target_child_id": child_id,
"expires_at": expires_at,
},
)
self.commit()
return bind_token
def get_session(self, bind_token: str, user_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text(
"SELECT * FROM device_bind_sessions WHERE bind_token = :bind_token AND initiator_user_id = :user_id"
),
{"bind_token": bind_token, "user_id": user_id},
)
.mappings()
.first()
)
def confirm_bind(self, session_id: int, device_id: str, child_id: int, user_id: int) -> None:
self.db.execute(
text("UPDATE device_bind_sessions SET status = 2, confirmed_at = CURRENT_TIMESTAMP WHERE id = :id"),
{"id": session_id},
)
existing = self.db.execute(
text("SELECT id FROM device_bindings WHERE device_id = :device_id OR child_id = :child_id"),
{"device_id": device_id, "child_id": child_id},
).scalar_one_or_none()
if existing:
self.db.execute(
text(
"UPDATE device_bindings SET child_id = :child_id, status = 1, unbound_at = NULL WHERE device_id = :device_id"
),
{"device_id": device_id, "child_id": child_id},
)
else:
self.db.execute(
text(
"INSERT INTO device_bindings (device_id, child_id, status, bound_at) VALUES (:device_id, :child_id, 1, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id},
)
self.db.execute(
text(
"INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, bind_source, bound_at) VALUES (:device_id, :child_id, :user_id, 1, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_id},
)
self.commit()
def direct_bind(self, device_id: str, child_id: int, user_id: int) -> None:
# Check if binding exists for this device_id
existing_by_device = (
self.db.execute(
text("SELECT id FROM device_bindings WHERE device_id = :device_id"),
{"device_id": device_id},
).fetchone()
)
# Check if this child is already bound to a different device
existing_by_child = (
self.db.execute(
text("SELECT id FROM device_bindings WHERE child_id = :child_id AND status = 1"),
{"child_id": child_id},
).fetchone()
)
if existing_by_device:
# Update existing binding for this device
self.db.execute(
text(
"UPDATE device_bindings SET child_id = :child_id, status = 1, unbound_at = NULL WHERE device_id = :device_id"
),
{"device_id": device_id, "child_id": child_id},
)
self.db.execute(
text(
"INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, bind_source, bound_at) VALUES (:device_id, :child_id, :user_id, 2, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_id},
)
self.commit()
return
elif existing_by_child:
# Child already bound to another device - update that binding
self.db.execute(
text(
"UPDATE device_bindings SET device_id = :device_id, status = 1, unbound_at = NULL WHERE child_id = :child_id AND status = 1"
),
{"device_id": device_id, "child_id": child_id},
)
# Skip INSERT since we updated
self.db.execute(
text(
"INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, bind_source, bound_at) VALUES (:device_id, :child_id, :user_id, 2, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_id},
)
self.commit()
return
else:
# Insert new binding
self.db.execute(
text(
"INSERT INTO device_bindings (device_id, child_id, status, bound_at) VALUES (:device_id, :child_id, 1, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id},
)
# Add history
self.db.execute(
text(
"INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, bind_source, bound_at) VALUES (:device_id, :child_id, :user_id, 2, CURRENT_TIMESTAMP)"
),
{"device_id": device_id, "child_id": child_id, "user_id": user_id},
)
self.commit()
def get_current_by_user(self, user_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text(
"""
SELECT b.* FROM device_bindings b
JOIN children c ON b.child_id = c.child_id
WHERE c.parent_user_id = :user_id AND b.status = 1
ORDER BY b.bound_at DESC
LIMIT 1
"""
),
{"user_id": user_id},
)
.mappings()
.first()
)
def get_by_device(self, device_id: str, user_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text(
"""
SELECT b.* FROM device_bindings b
JOIN children c ON b.child_id = c.child_id
WHERE b.device_id = :device_id AND c.parent_user_id = :user_id AND b.status = 1
"""
),
{"device_id": device_id, "user_id": user_id},
)
.mappings()
.first()
)
def unbind(self, device_id: str, user_id: int) -> bool:
row = self.get_by_device(device_id, user_id)
if not row:
return False
self.db.execute(
text("UPDATE device_bindings SET status = 0, unbound_at = CURRENT_TIMESTAMP WHERE id = :id"),
{"id": row["id"]},
)
self.db.execute(
text(
"INSERT INTO device_bind_history (device_id, child_id, bound_by_user_id, unbound_by_user_id, bind_source, bound_at, unbound_at, unbind_reason) SELECT device_id, child_id, bound_by_user_id, :user_id, bind_source, bound_at, CURRENT_TIMESTAMP, 'user_unbind' FROM device_bind_history WHERE device_id = :device_id AND unbound_at IS NULL"
),
{"device_id": device_id, "user_id": user_id},
)
self.commit()
return True
def list_history(self, device_id: str, limit: int = 20, cursor: datetime = None) -> list[Mapping]:
params = {"device_id": device_id, "limit": limit + 1}
where = "device_id = :device_id"
if cursor:
where += " AND bound_at < :cursor"
params["cursor"] = cursor
rows = (
self.db.execute(
text(
f"""
SELECT * FROM device_bind_history
WHERE {where}
ORDER BY bound_at DESC
LIMIT :limit
"""
),
params,
)
.mappings()
.all()
)
return rows

View File

@@ -0,0 +1,95 @@
from collections.abc import Mapping
from datetime import date
from typing import Optional
from sqlalchemy import text
from app.dao import BaseDAO
class ChildDAO(BaseDAO):
def create(
self,
user_id: int,
child_name: str,
child_gender: int = 2,
child_birthday: Optional[date] = None,
) -> int:
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)
"""
),
{"user_id": user_id, "child_name": child_name, "child_gender": child_gender, "child_birthday": child_birthday},
)
child_id = int(result.lastrowid)
self.commit()
return child_id
def get_by_id(self, child_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text("SELECT * FROM children WHERE child_id = :child_id"),
{"child_id": child_id},
)
.mappings()
.first()
)
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"
params["cursor"] = cursor
rows = (
self.db.execute(
text(
f"""
SELECT * FROM children
WHERE {where}
ORDER BY child_id DESC
LIMIT :limit
"""
),
params,
)
.mappings()
.all()
)
return rows
def update(
self,
child_id: int,
child_name: Optional[str] = None,
child_gender: Optional[int] = None,
child_birthday: Optional[date] = None,
) -> None:
self.db.execute(
text(
"""
UPDATE children
SET child_name = COALESCE(:child_name, child_name),
child_gender = COALESCE(:child_gender, child_gender),
child_birthday = COALESCE(:child_birthday, child_birthday)
WHERE child_id = :child_id
"""
),
{"child_id": child_id, "child_name": child_name, "child_gender": child_gender, "child_birthday": child_birthday},
)
self.commit()
def has_access(self, child_id: int, user_id: int) -> bool:
return (
self.db.execute(
text(
"SELECT 1 FROM children WHERE child_id = :child_id AND parent_user_id = :user_id AND status = 1"
),
{"child_id": child_id, "user_id": user_id},
).scalar_one_or_none()
is not None
)

View File

@@ -0,0 +1,100 @@
from collections.abc import Mapping
from typing import Optional
from sqlalchemy import text
from app.dao import BaseDAO
class ParentDAO(BaseDAO):
def create(
self,
openid: str,
unionid: Optional[str] = None,
nickname: Optional[str] = None,
avatar_url: Optional[str] = None,
) -> int:
result = self.db.execute(
text(
"""
INSERT INTO parents (openid, unionid, nickname, avatar_url, status)
VALUES (:openid, :unionid, :nickname, :avatar_url, 1)
"""
),
{"openid": openid, "unionid": unionid, "nickname": nickname, "avatar_url": avatar_url},
)
self.commit()
return int(self.db.execute(text("SELECT last_insert_rowid()")).scalar_one())
def get_by_id(self, user_id: int) -> Optional[Mapping]:
return (
self.db.execute(
text("SELECT * FROM parents WHERE user_id = :user_id"),
{"user_id": user_id},
)
.mappings()
.first()
)
def get_by_openid(self, openid: str) -> Optional[Mapping]:
return (
self.db.execute(
text("SELECT * FROM parents WHERE openid = :openid"),
{"openid": openid},
)
.mappings()
.first()
)
def update(
self,
user_id: int,
nickname: Optional[str] = None,
avatar_url: Optional[str] = None,
phone: Optional[str] = None,
) -> None:
self.db.execute(
text(
"""
UPDATE parents
SET nickname = COALESCE(:nickname, nickname),
avatar_url = COALESCE(:avatar_url, avatar_url),
phone = COALESCE(:phone, phone)
WHERE user_id = :user_id
"""
),
{"user_id": user_id, "nickname": nickname, "avatar_url": avatar_url, "phone": phone},
)
self.commit()
def upsert(
self,
openid: str,
unionid: Optional[str] = None,
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"]
if settings.db_type == "sqlite":
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())

View File

@@ -13,14 +13,34 @@ except ModuleNotFoundError:
from settings import settings
engine = create_engine(
settings.mysql_dsn,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
pool_recycle=1800,
future=True,
)
def _create_engine() -> create_engine:
if settings.db_type == "sqlite":
return create_engine(
settings.sqlite_dsn,
connect_args={"check_same_thread": False},
future=True,
)
return create_engine(
settings.mysql_dsn,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
pool_recycle=1800,
future=True,
)
engine = _create_engine()
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
if settings.db_type == "sqlite":
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
logger = logging.getLogger("app.db")
@@ -79,6 +99,13 @@ def get_db() -> Generator[Session, None, None]:
db.close()
def init_db_tables() -> None:
from app.models import Base
Base.metadata.create_all(bind=engine)
logger.info("database tables initialized", extra={"event": "db_init"})
def check_db_connection() -> None:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))

View File

@@ -5,23 +5,30 @@ from fastapi import FastAPI
try:
# For module mode: `uvicorn app.main:app`
from app.db import check_db_connection, close_db_engine
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
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
@@ -39,6 +46,7 @@ def create_app() -> FastAPI:
@app.on_event("startup")
def on_startup() -> None:
check_db_connection()
init_db_tables()
logger.info("application started", extra={"event": "app_start"})
@app.on_event("shutdown")
@@ -48,9 +56,15 @@ def create_app() -> FastAPI:
install_auth_middleware(app)
install_request_logging_middleware(app)
app.include_router(auth_router)
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
return app

View File

@@ -14,7 +14,7 @@ except ModuleNotFoundError:
from security import auth_error_response, decode_access_token
EXCLUDED_PATH_PREFIXES = (
NO_AUTH_PATH_PREFIXES = (
"/health",
"/docs",
"/redoc",
@@ -22,11 +22,22 @@ EXCLUDED_PATH_PREFIXES = (
"/auth/login",
)
logger = logging.getLogger("app.auth")
def _is_no_auth_path(path: str) -> bool:
for prefix in NO_AUTH_PATH_PREFIXES:
if path == prefix or path.startswith(f"{prefix}/"):
return True
return False
def _is_excluded_path(path: str) -> bool:
for prefix in EXCLUDED_PATH_PREFIXES:
return _is_no_auth_path(path)
logger = logging.getLogger("app.auth")
def _is_no_auth_path(path: str) -> bool:
for prefix in NO_AUTH_PATH_PREFIXES:
if path == prefix or path.startswith(f"{prefix}/"):
return True
return False
@@ -39,7 +50,7 @@ def install_auth_middleware(app: FastAPI) -> None:
call_next: Callable[[Request], Awaitable],
):
path = request.url.path
if request.method == "OPTIONS" or _is_excluded_path(path):
if request.method == "OPTIONS" or _is_no_auth_path(path):
return await call_next(request)
auth_header = request.headers.get("Authorization")
@@ -87,9 +98,9 @@ def install_auth_middleware(app: FastAPI) -> None:
db.execute(
text(
"""
SELECT id, status
FROM chat_user
WHERE id = :user_id
SELECT user_id, status
FROM parents
WHERE user_id = :user_id
LIMIT 1
"""
),

View File

@@ -0,0 +1,37 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
from app.models.entities import (
Parent,
Child,
ParentChildRelation,
DeviceBinding,
DeviceBindSession,
DeviceBindHistory,
Card,
DeviceSetting,
IMConversation,
IMMessage,
ChildLocationCurrent,
ChildLocationHistory,
)
__all__ = [
"Base",
"Parent",
"Child",
"ParentChildRelation",
"DeviceBinding",
"DeviceBindSession",
"DeviceBindHistory",
"Card",
"DeviceSetting",
"IMConversation",
"IMMessage",
"ChildLocationCurrent",
"ChildLocationHistory",
]

View File

@@ -0,0 +1,258 @@
from datetime import date, datetime, time
from typing import Optional
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Date,
DateTime,
Index,
Integer,
Numeric,
SmallInteger,
String,
Text,
Time,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.models import Base
class Parent(Base):
__tablename__ = "parents"
user_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
openid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
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))
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="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
class Child(Base):
__tablename__ = "children"
__table_args__ = (
Index("idx_child_parent_user_id", "parent_user_id"),
)
child_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
parent_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
child_name: Mapped[str] = mapped_column(String(32), nullable=False)
child_gender: Mapped[int] = mapped_column(Integer, server_default="2")
child_birthday: Mapped[Optional[date]] = mapped_column(Date)
status: Mapped[int] = mapped_column(Integer, server_default="1")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
class ParentChildRelation(Base):
__tablename__ = "parent_child_relations"
__table_args__ = (
UniqueConstraint("user_id", "child_id", name="uq_user_child"),
Index("idx_pcr_user_id", "user_id"),
Index("idx_pcr_child_id", "child_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
child_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
relation_type: Mapped[int] = mapped_column(Integer, server_default="9")
is_primary: Mapped[bool] = mapped_column(Boolean, server_default="0")
status: Mapped[int] = mapped_column(Integer, server_default="1")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
class DeviceBinding(Base):
__tablename__ = "device_bindings"
__table_args__ = (
UniqueConstraint("device_id", name="uq_device_binding_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[int] = mapped_column(Integer, server_default="1")
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
class DeviceBindSession(Base):
__tablename__ = "device_bind_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
bind_token: Mapped[str] = mapped_column(String(36), unique=True, nullable=False)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
initiator_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
target_child_id: Mapped[Optional[int]] = mapped_column(Integer)
challenge_code_hash: Mapped[Optional[str]] = mapped_column(String(64))
challenge_set_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
max_attempt_count: Mapped[int] = mapped_column(Integer, server_default="5")
attempt_count: Mapped[int] = mapped_column(Integer, server_default="0")
status: Mapped[int] = mapped_column(Integer, server_default="1")
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
consumed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
class DeviceBindHistory(Base):
__tablename__ = "device_bind_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
child_id: Mapped[int] = mapped_column(Integer, nullable=False)
bound_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
unbound_by_user_id: Mapped[Optional[int]] = mapped_column(Integer)
bind_source: Mapped[int] = mapped_column(Integer, server_default="1")
bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
unbound_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
unbind_reason: Mapped[Optional[str]] = mapped_column(String(191))
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
class Card(Base):
__tablename__ = "cards"
card_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
card_uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
device_id: Mapped[Optional[str]] = mapped_column(String(64), unique=True)
card_name: Mapped[Optional[str]] = mapped_column(String(64))
status: Mapped[int] = mapped_column(Integer, server_default="0")
total_swaps: Mapped[int] = mapped_column(Integer, server_default="0")
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
class DeviceSetting(Base):
__tablename__ = "device_settings"
setting_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
sleep_mode: Mapped[int] = mapped_column(Integer, server_default="0")
disable_time_start: Mapped[Optional[time]] = mapped_column(Time)
disable_time_end: Mapped[Optional[time]] = mapped_column(Time)
timezone: Mapped[str] = mapped_column(String(32), server_default="'Asia/Shanghai'")
volume: Mapped[Optional[int]] = mapped_column(Integer)
brightness: Mapped[Optional[int]] = mapped_column(Integer)
disable_weekdays: Mapped[Optional[str]] = mapped_column(String(32))
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, server_default="CURRENT_TIMESTAMP")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
class IMConversation(Base):
__tablename__ = "im_conversations"
__table_args__ = (
UniqueConstraint("conversation_type", "pair_key", name="uq_conv_type_pair"),
Index("idx_im_conv_participant_a", "participant_a_type", "participant_a_id"),
Index("idx_im_conv_participant_b", "participant_b_type", "participant_b_id"),
Index("idx_im_conv_last_message_at", "last_message_at"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
conversation_type: Mapped[int] = mapped_column(Integer, nullable=False)
participant_a_type: Mapped[int] = mapped_column(Integer, nullable=False)
participant_a_id: Mapped[str] = mapped_column(String(64), nullable=False)
participant_b_type: Mapped[int] = mapped_column(Integer, nullable=False)
participant_b_id: Mapped[str] = mapped_column(String(64), nullable=False)
pair_key: Mapped[str] = mapped_column(String(191), nullable=False)
status: Mapped[int] = mapped_column(Integer, default=1)
last_seq: Mapped[int] = mapped_column(BigInteger, default=0, index=True)
message_count: Mapped[int] = mapped_column(BigInteger, default=0)
last_message_preview: Mapped[Optional[str]] = mapped_column(String(255))
last_message_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
ext_json: Mapped[Optional[dict]] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
class IMMessage(Base):
__tablename__ = "im_messages"
__table_args__ = (
UniqueConstraint("conversation_id", "seq", name="uq_im_msg_conv_seq"),
UniqueConstraint("conversation_id", "client_msg_id", name="uq_im_msg_client"),
Index("idx_im_msg_sender", "sender_type", "sender_id", "created_at"),
Index("idx_im_msg_receiver", "receiver_type", "receiver_id", "created_at"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
conversation_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
seq: Mapped[int] = mapped_column(BigInteger, nullable=False)
sender_type: Mapped[int] = mapped_column(Integer, nullable=False)
sender_id: Mapped[str] = mapped_column(String(64), nullable=False)
receiver_type: Mapped[int] = mapped_column(Integer, nullable=False)
receiver_id: Mapped[str] = mapped_column(String(64), nullable=False)
content_type: Mapped[int] = mapped_column(Integer, nullable=False)
content_text: Mapped[Optional[str]] = mapped_column(Text)
content_json: Mapped[Optional[dict]] = mapped_column(JSON)
media_file_key: Mapped[Optional[str]] = mapped_column(String(255))
media_duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
media_mime_type: Mapped[Optional[str]] = mapped_column(String(64))
media_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
media_transcript_text: Mapped[Optional[str]] = mapped_column(Text)
client_msg_id: Mapped[Optional[str]] = mapped_column(String(64))
sender_name_snapshot: Mapped[Optional[str]] = mapped_column(String(64))
sender_avatar_snapshot: Mapped[Optional[str]] = mapped_column(String(255))
receiver_name_snapshot: Mapped[Optional[str]] = mapped_column(String(64))
receiver_avatar_snapshot: Mapped[Optional[str]] = mapped_column(String(255))
ext_json: Mapped[Optional[dict]] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
class ChildLocationCurrent(Base):
__tablename__ = "child_location_current"
child_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
coord_type: Mapped[str] = mapped_column(String(16), default="gcj02")
lat: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False)
lng: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False)
accuracy_m: Mapped[Optional[int]] = mapped_column(Integer)
altitude_m: Mapped[Optional[float]] = mapped_column(Numeric(8, 2))
speed_mps: Mapped[Optional[float]] = mapped_column(Numeric(8, 2))
heading_deg: Mapped[Optional[int]] = mapped_column(SmallInteger)
source: Mapped[int] = mapped_column(Integer, nullable=False)
battery_pct: Mapped[Optional[int]] = mapped_column(Integer)
device_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
server_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
class ChildLocationHistory(Base):
__tablename__ = "child_location_history"
__table_args__ = (
Index("idx_child_loc_hist_child", "child_id", "created_at"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
child_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
coord_type: Mapped[str] = mapped_column(String(16), default="gcj02")
lat: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False)
lng: Mapped[float] = mapped_column(Numeric(10, 7), nullable=False)
accuracy_m: Mapped[Optional[int]] = mapped_column(Integer)
altitude_m: Mapped[Optional[float]] = mapped_column(Numeric(8, 2))
speed_mps: Mapped[Optional[float]] = mapped_column(Numeric(8, 2))
heading_deg: Mapped[Optional[int]] = mapped_column(SmallInteger)
source: Mapped[int] = mapped_column(Integer, nullable=False)
battery_pct: Mapped[Optional[int]] = mapped_column(Integer)
device_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
server_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

BIN
mini-program/app/routers/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,164 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
try:
from app.security import get_current_user_id
from app.service.binding import BindingService
from app.service import get_db_session
except ModuleNotFoundError:
from security import get_current_user_id
from service.binding import BindingService
from service import get_db_session
router = APIRouter(prefix="/bindings", tags=["bindings"])
logger = logging.getLogger("app.bindings")
class BindStartRequest(BaseModel):
device_id: str
child_id: int
class BindStartResponse(BaseModel):
bind_token: str
expires_at: str
class BindConfirmRequest(BaseModel):
bind_token: str
challenge_code: str
class BindConfirmResponse(BaseModel):
device_id: str
child_id: int
class BindingGetResponse(BaseModel):
device_id: str
child_id: int
status: int
bound_at: str
class BindHistoryItem(BaseModel):
device_id: str
child_id: int
bound_at: str
unbound_at: str | None
class BindHistoryResponse(BaseModel):
items: list[BindHistoryItem]
total: int
next_cursor: str | None
@router.post("/start", response_model=BindStartResponse)
def start_bind(
payload: BindStartRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> BindStartResponse:
service = BindingService(db)
bind_token, expires_at = service.start_bind(current_user_id, payload.device_id, payload.child_id)
return BindStartResponse(bind_token=bind_token, expires_at=expires_at.isoformat())
@router.post("/confirm", response_model=BindConfirmResponse)
def confirm_bind(
payload: BindConfirmRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> BindConfirmResponse:
service = BindingService(db)
try:
result = service.confirm_bind(payload.bind_token, current_user_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return BindConfirmResponse(**result)
class DirectBindRequest(BaseModel):
device_id: str
child_id: int
class DirectBindResponse(BaseModel):
device_id: str
child_id: int
@router.post("/direct", response_model=DirectBindResponse)
def direct_bind(
payload: DirectBindRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> DirectBindResponse:
service = BindingService(db)
result = service.direct_bind(payload.device_id, payload.child_id, current_user_id)
return DirectBindResponse(**result)
@router.get("/current", response_model=BindingGetResponse)
def get_current_binding(
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
):
service = BindingService(db)
binding = service.get_current_binding(current_user_id)
if not binding:
raise HTTPException(status_code=404, detail="no binding found")
return binding
@router.get("/{device_id}", response_model=BindingGetResponse)
def get_binding(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> BindingGetResponse:
service = BindingService(db)
binding = service.get_binding(device_id, current_user_id)
if not binding:
raise HTTPException(status_code=404, detail="binding not found")
return BindingGetResponse(**binding)
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
def unbind_device(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> None:
service = BindingService(db)
if not service.unbind(device_id, current_user_id):
raise HTTPException(status_code=404, detail="binding not found")
@router.get("/history/{device_id}", response_model=BindHistoryResponse)
def get_bind_history(
device_id: str,
request: Request,
cursor: str | None = None,
limit: int = 20,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> BindHistoryResponse:
from datetime import datetime
cursor_dt = datetime.fromisoformat(cursor) if cursor else None
service = BindingService(db)
rows, has_more = service.list_history(device_id, limit, cursor_dt)
next_cursor = rows[-1]["bound_at"].isoformat() if has_more and rows else None
items = [BindHistoryItem(**row) for row in rows]
return BindHistoryResponse(items=items, total=len(items), next_cursor=next_cursor)

View File

@@ -0,0 +1,102 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
try:
from app.security import get_current_user_id
from app.service.child import ChildService
from app.service import get_db_session
except ModuleNotFoundError:
from security import get_current_user_id
from service.child import ChildService
from service import get_db_session
router = APIRouter(prefix="/children", tags=["children"])
logger = logging.getLogger("app.children")
class ChildCreateRequest(BaseModel):
child_name: str
child_gender: int = 2
child_birthday: str | None = None
class ChildResponse(BaseModel):
child_id: int
child_name: str
child_gender: int
child_birthday: str | None = None
status: int
class ChildUpdateRequest(BaseModel):
child_name: str | None = None
child_gender: int | None = None
child_birthday: str | None = None
class ChildListResponse(BaseModel):
items: list[ChildResponse]
total: int
next_cursor: int | None = None
@router.post("", response_model=ChildResponse, status_code=status.HTTP_201_CREATED)
def create_child(
payload: ChildCreateRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> ChildResponse:
service = ChildService(db)
child = service.create(current_user_id, payload.child_name, payload.child_gender, payload.child_birthday)
return ChildResponse(**child)
@router.get("", response_model=ChildListResponse)
def list_children(
request: Request,
cursor: int | None = None,
limit: int = 20,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> ChildListResponse:
service = ChildService(db)
rows, has_more = service.list_children(current_user_id, limit, cursor)
next_cursor = rows[-1]["child_id"] if has_more and rows else None
items = [ChildResponse(**row) for row in rows]
return ChildListResponse(items=items, total=len(items), next_cursor=next_cursor)
@router.get("/{child_id}", response_model=ChildResponse)
def get_child(
child_id: int,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> ChildResponse:
service = ChildService(db)
child = service.get(child_id)
if not child:
raise HTTPException(status_code=404, detail="child not found")
return ChildResponse(**child)
@router.patch("/{child_id}", response_model=ChildResponse)
def update_child(
child_id: int,
payload: ChildUpdateRequest,
request: Request,
current_user_id: int = Depends(get_current_user_id),
db=Depends(get_db_session),
) -> ChildResponse:
service = ChildService(db)
try:
child = service.update(child_id, current_user_id, payload.child_name, payload.child_gender, payload.child_birthday)
except PermissionError:
raise HTTPException(status_code=403, detail="no permission to access this child")
if not child:
raise HTTPException(status_code=404, detail="child not found")
return ChildResponse(**child)

View File

@@ -0,0 +1,63 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
try:
from app.service.parent import ParentService
from app.service import get_db_session
except ModuleNotFoundError:
from service.parent import ParentService
from service import get_db_session
router = APIRouter(prefix="/parents", tags=["parents"])
logger = logging.getLogger("app.parents")
class ParentCreateRequest(BaseModel):
openid: str
unionid: str | None = None
nickname: str | None = None
avatar_url: str | None = None
class ParentResponse(BaseModel):
user_id: int
openid: str
unionid: str | None = None
nickname: str | None = None
avatar_url: str | None = None
phone: str | None = None
status: int
class ParentUpdateRequest(BaseModel):
nickname: str | None = None
avatar_url: str | None = None
phone: str | 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)
parent = service.create(payload.openid, payload.unionid, payload.nickname, payload.avatar_url)
return ParentResponse(**parent)
@router.get("/{user_id}", response_model=ParentResponse)
def get_parent(user_id: int, request: Request, db=Depends(get_db_session)) -> ParentResponse:
service = ParentService(db)
parent = service.get(user_id)
if not parent:
raise HTTPException(status_code=404, detail="parent not found")
return ParentResponse(**parent)
@router.patch("/{user_id}", response_model=ParentResponse)
def update_parent(user_id: int, payload: ParentUpdateRequest, request: Request, db=Depends(get_db_session)) -> ParentResponse:
service = ParentService(db)
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)

View File

@@ -0,0 +1,87 @@
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
try:
from app.db import get_db
from app.security import create_access_token
except ModuleNotFoundError:
from db import get_db
from security import create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
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
class LoginResponse(BaseModel):
access_token: str
expires_in: int
user_id: int
@router.post("/login", response_model=LoginResponse)
def login(
payload: LoginRequest,
request: Request,
db: Session = Depends(get_db),
) -> 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")
with db.begin():
row = (
db.execute(
text("SELECT user_id FROM parents WHERE openid = :openid"),
{"openid": identifier},
).mappings().first()
)
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}")
access_token, expires_in = create_access_token(user_id=user_id)
logger.info(f"Login succeeded: user_id={user_id}, expires_in={expires_in}")
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"}

BIN
mini-program/app/service/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
from fastapi import Depends
from sqlalchemy.orm import Session
from app.db import get_db
def get_db_session():
db = next(get_db())
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,45 @@
from collections.abc import Mapping
from datetime import datetime
from typing import Optional
from app.dao.binding import BindingDAO
class BindingService:
def __init__(self, db):
self.dao = BindingDAO(db)
def start_bind(self, user_id: int, device_id: str, child_id: int) -> tuple[str, datetime]:
bind_token = self.dao.start_bind(user_id, device_id, child_id)
return bind_token, datetime.utcnow()
def confirm_bind(self, bind_token: str, user_id: int) -> Mapping:
session = self.dao.get_session(bind_token, user_id)
if not session:
raise ValueError("Bind session not found")
if datetime.utcnow() > session["expires_at"]:
raise ValueError("Bind session expired")
if session["status"] != 1:
raise ValueError("Bind session already processed")
self.dao.confirm_bind(session["id"], session["device_id"], session["target_child_id"], user_id)
return {"device_id": session["device_id"], "child_id": session["target_child_id"]}
def get_binding(self, device_id: str, user_id: int) -> Optional[Mapping]:
return self.dao.get_by_device(device_id, user_id)
def get_current_binding(self, user_id: int) -> Optional[Mapping]:
return self.dao.get_current_by_user(user_id)
def direct_bind(self, device_id: str, child_id: int, user_id: int) -> Mapping:
self.dao.direct_bind(device_id, child_id, user_id)
return {"device_id": device_id, "child_id": child_id}
def unbind(self, device_id: str, user_id: int) -> bool:
return self.dao.unbind(device_id, user_id)
def list_history(self, device_id: str, limit: int = 20, cursor: datetime = None) -> tuple[list, bool]:
rows = self.dao.list_history(device_id, limit, cursor)
has_more = len(rows) > limit
rows = rows[:limit]
return rows, has_more

View File

@@ -0,0 +1,42 @@
from collections.abc import Mapping
from datetime import date
from typing import Optional
from app.dao.child import ChildDAO
class ChildService:
def __init__(self, db):
self.dao = ChildDAO(db)
def create(
self,
user_id: int,
child_name: str,
child_gender: int = 2,
child_birthday: Optional[date] = None,
) -> Mapping:
child_id = self.dao.create(user_id, child_name, child_gender, child_birthday)
return self.dao.get_by_id(child_id)
def list_children(self, user_id: int, limit: int = 20, cursor: int = None) -> tuple[list, bool]:
rows = self.dao.list_by_parent(user_id, limit, cursor)
has_more = len(rows) > limit
rows = rows[:limit]
return rows, has_more
def get(self, child_id: int) -> Optional[Mapping]:
return self.dao.get_by_id(child_id)
def update(
self,
child_id: int,
user_id: int,
child_name: Optional[str] = None,
child_gender: Optional[int] = None,
child_birthday: Optional[date] = None,
) -> Mapping:
if not self.dao.has_access(child_id, user_id):
raise PermissionError("No access to this child")
self.dao.update(child_id, child_name, child_gender, child_birthday)
return self.dao.get_by_id(child_id)

View File

@@ -0,0 +1,33 @@
from collections.abc import Mapping
from typing import Optional
from app.dao.parent import ParentDAO
from app.service import get_db_session
class ParentService:
def __init__(self, db):
self.dao = ParentDAO(db)
def create(
self,
openid: str,
unionid: Optional[str] = None,
nickname: Optional[str] = None,
avatar_url: Optional[str] = None,
) -> Mapping:
user_id = self.dao.upsert(openid, unionid, nickname, avatar_url)
return self.dao.get_by_id(user_id)
def get(self, user_id: int) -> Optional[Mapping]:
return self.dao.get_by_id(user_id)
def update(
self,
user_id: int,
nickname: Optional[str] = None,
avatar_url: Optional[str] = None,
phone: Optional[str] = None,
) -> Mapping:
self.dao.update(user_id, nickname, avatar_url, phone)
return self.dao.get_by_id(user_id)

View File

@@ -1,4 +1,5 @@
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote_plus
from pydantic import Field
@@ -21,11 +22,13 @@ class Settings(BaseSettings):
host: str = Field(default="0.0.0.0", validation_alias="HOST")
port: int = Field(default=8001, validation_alias="PORT")
db_type: str = Field(default="mysql", validation_alias="DB_TYPE")
db_host: str = Field(default="127.0.0.1", validation_alias="DB_HOST")
db_port: int = Field(default=3306, validation_alias="DB_PORT")
db_user: str = Field(default="root", validation_alias="DB_USER")
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
db_name: str = Field(default="mini_program", validation_alias="DB_NAME")
db_path: str = Field(default="./data.db", validation_alias="DB_PATH")
jwt_secret: str = Field(
default="dev_only_change_jwt_secret",
validation_alias="JWT_SECRET",
@@ -49,6 +52,18 @@ class Settings(BaseSettings):
f"{self.db_name}?charset=utf8mb4"
)
@property
def sqlite_dsn(self) -> str:
db_path = Path(self.db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{db_path.absolute()}"
@property
def database_dsn(self) -> str:
if self.db_type == "sqlite":
return self.sqlite_dsn
return self.mysql_dsn
@lru_cache(maxsize=1)
def get_settings() -> Settings: