新增小程序后端代码,包括数据库、路由、服务层等。
小程序前后端打通,包括登录、注册、绑定设备、查询绑定信息,修改小朋友名称等功能。
This commit is contained in:
BIN
mini-program/app/.DS_Store
vendored
Normal file
BIN
mini-program/app/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
mini-program/app/dao/.DS_Store
vendored
Normal file
BIN
mini-program/app/dao/.DS_Store
vendored
Normal file
Binary file not shown.
13
mini-program/app/dao/__init__.py
Normal file
13
mini-program/app/dao/__init__.py
Normal 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()
|
||||
226
mini-program/app/dao/binding.py
Normal file
226
mini-program/app/dao/binding.py
Normal 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
|
||||
95
mini-program/app/dao/child.py
Normal file
95
mini-program/app/dao/child.py
Normal 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
|
||||
)
|
||||
100
mini-program/app/dao/parent.py
Normal file
100
mini-program/app/dao/parent.py
Normal 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())
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
),
|
||||
|
||||
37
mini-program/app/models/__init__.py
Normal file
37
mini-program/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
258
mini-program/app/models/entities.py
Normal file
258
mini-program/app/models/entities.py
Normal 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
BIN
mini-program/app/routers/.DS_Store
vendored
Normal file
Binary file not shown.
164
mini-program/app/routers/bindings.py
Normal file
164
mini-program/app/routers/bindings.py
Normal 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)
|
||||
102
mini-program/app/routers/children.py
Normal file
102
mini-program/app/routers/children.py
Normal 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)
|
||||
63
mini-program/app/routers/parents.py
Normal file
63
mini-program/app/routers/parents.py
Normal 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)
|
||||
87
mini-program/app/routers/wechat_auth.py
Normal file
87
mini-program/app/routers/wechat_auth.py
Normal 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
BIN
mini-program/app/service/.DS_Store
vendored
Normal file
Binary file not shown.
12
mini-program/app/service/__init__.py
Normal file
12
mini-program/app/service/__init__.py
Normal 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()
|
||||
45
mini-program/app/service/binding.py
Normal file
45
mini-program/app/service/binding.py
Normal 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
|
||||
42
mini-program/app/service/child.py
Normal file
42
mini-program/app/service/child.py
Normal 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)
|
||||
33
mini-program/app/service/parent.py
Normal file
33
mini-program/app/service/parent.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user