Files
life-echo/api/database/models.py
Sully c2ce4c61f1 修复版本1.0.7的若干问题 (#11)
* fix/ 0:00 audio ui

* fix/ persist memoir image state and collapse voice history

Keep generated chapter images from staying in processing after successful uploads, and restore segmented voice recordings as a single audio message when reopening conversations.

Made-with: Cursor

* fix/ persist local conversation state and stabilize voice UI

Keep CreateMemory conversations driven by Room so recent text and audio survive page exits, and prevent stale 0:00 voice bubbles while list ordering follows the latest local message time.

Made-with: Cursor

* fix/ server-side root cause for conversation list time and message timestamps

- Add Conversation.last_message_at column with migration and index
- Update last_message_at on text message, audio segment, and AI response
- Sort conversation list by COALESCE(last_message_at, started_at) DESC
- Return real per-message timestamps from Redis history instead of now()
- Pass user_message_timestamp through agent pipeline to avoid LLM delay skew
- Remove all debug logging from server, client, and CI workflow
- Restore import json in conversation_agent (was broken by debug removal)
- Client: remove DebugRuntimeLogger, stop sending transcript as text message

Made-with: Cursor

---------

Co-authored-by: Kevin <kevin@brighteng.org>
2026-03-14 23:58:46 +08:00

256 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
数据库模型定义
"""
from datetime import datetime, timezone
from typing import Optional, List
from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text, ForeignKey, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
def utc_now():
"""返回当前 UTC 时间(带时区信息)"""
return datetime.now(timezone.utc)
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(String, primary_key=True)
phone = Column(String, unique=True, nullable=False, index=True) # 手机号(唯一,必填)
password_hash = Column(String, nullable=False) # 密码哈希
email = Column(String, unique=True, nullable=True) # 邮箱(可选)
openid = Column(String, unique=True, nullable=True) # 微信 OpenID可选
nickname = Column(String, nullable=False)
avatar_url = Column(String, nullable=True)
subscription_type = Column(String, default="free") # free, premium
subscription_expires_at = Column(DateTime(timezone=True), nullable=True) # 订阅到期时间
created_at = Column(DateTime(timezone=True), default=utc_now)
birth_year = Column(Integer, nullable=True)
birth_place = Column(String, nullable=True)
grew_up_place = Column(String, nullable=True)
occupation = Column(String, nullable=True)
# Relationships
conversations = relationship("Conversation", back_populates="user")
chapters = relationship("Chapter", back_populates="user")
books = relationship("Book", back_populates="user")
orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")
memoir_state = relationship("MemoirState", back_populates="user", uselist=False, cascade="all, delete-orphan")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
class Conversation(Base):
"""对话会话表"""
__tablename__ = "conversations"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime(timezone=True), default=utc_now)
last_message_at = Column(DateTime(timezone=True), nullable=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, default=0)
summary = Column(Text, nullable=True)
status = Column(String, default="active") # active, ended, processing
current_topic = Column(String, nullable=True)
conversation_stage = Column(String, nullable=True) # childhood, education, career, family, beliefs, summary
# Relationships
user = relationship("User", back_populates="conversations")
segments = relationship("Segment", back_populates="conversation", cascade="all, delete-orphan")
class Segment(Base):
"""对话段落表"""
__tablename__ = "segments"
id = Column(String, primary_key=True)
conversation_id = Column(String, ForeignKey("conversations.id"), nullable=False)
audio_url = Column(String, nullable=True)
transcript_text = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), default=utc_now)
processed = Column(Boolean, default=False)
topic_category = Column(String, nullable=True)
agent_response = Column(Text, nullable=True)
# Relationships
conversation = relationship("Conversation", back_populates="segments")
class Chapter(Base):
"""章节表(正文与插图存于 chapter_sections"""
__tablename__ = "chapters"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
title = Column(String, nullable=False)
order_index = Column(Integer, nullable=False)
status = Column(String, default="draft") # draft, completed
cover_image = Column(JSON, nullable=True) # 章节封面图(单条图片元数据)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
category = Column(String, nullable=True) # 章节分类
is_new = Column(Boolean, default=True) # 是否为新内容(未读)
is_active = Column(Boolean, default=True) # 是否启用(清除回忆后置为 False
source_segments = Column(JSON, nullable=True) # 来源 segment IDs 列表
# Relationships
user = relationship("User", back_populates="chapters")
sections = relationship(
"ChapterSection",
back_populates="chapter",
order_by="ChapterSection.order_index",
cascade="all, delete-orphan",
)
images = relationship(
"MemoirImage",
back_populates="chapter",
foreign_keys="MemoirImage.chapter_id",
cascade="all, delete-orphan",
)
class ChapterSection(Base):
"""章节段落表:一章多段,每段一段正文 + 可选一张图"""
__tablename__ = "chapter_sections"
id = Column(String, primary_key=True)
chapter_id = Column(String, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False)
order_index = Column(Integer, nullable=False)
content = Column(Text, nullable=False) # 本段正文(无占位符)
image_id = Column(String, ForeignKey("memoir_images.id", ondelete="SET NULL"), nullable=True) # 关联 memoir_images.id
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
# Relationships
chapter = relationship("Chapter", back_populates="sections")
image_record = relationship(
"MemoirImage",
back_populates="section",
uselist=False,
foreign_keys="ChapterSection.image_id",
cascade="all, delete-orphan",
single_parent=True,
)
class MemoirImage(Base):
"""章节配图与封面字段独立存储section_id 为空表示章节封面,非空表示该 section 的配图"""
__tablename__ = "memoir_images"
id = Column(String, primary_key=True)
chapter_id = Column(String, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False)
section_id = Column(String, ForeignKey("chapter_sections.id", ondelete="CASCADE"), nullable=True)
order_index = Column(Integer, nullable=False, default=0)
placeholder = Column(Text, nullable=True)
description = Column(Text, nullable=True)
status = Column(String, nullable=False, default="pending")
prompt = Column(Text, nullable=True)
url = Column(Text, nullable=True)
storage_key = Column(Text, nullable=True)
provider = Column(String, nullable=True)
style = Column(String, nullable=True)
size = Column(String, nullable=True)
error = Column(Text, nullable=True)
retryable = Column(Boolean, nullable=True)
created_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
# Relationships
chapter = relationship("Chapter", back_populates="images")
section = relationship(
"ChapterSection",
back_populates="image_record",
foreign_keys="ChapterSection.image_id",
)
class Book(Base):
"""回忆录表"""
__tablename__ = "books"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
title = Column(String, nullable=False)
total_pages = Column(Integer, default=0)
total_words = Column(Integer, default=0)
cover_image_url = Column(String, nullable=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
has_update = Column(Boolean, default=False) # 是否有新内容
last_update_chapter_id = Column(String, nullable=True) # 最近更新的章节 ID
# Relationships
user = relationship("User", back_populates="books")
class MemoirState(Base):
"""回忆录状态表 - 对话 Agent 与后台 Agent 共享"""
__tablename__ = "memoir_states"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), unique=True, nullable=False)
stage_order = Column(JSON, default=list) # 阶段顺序
current_stage = Column(String, default="childhood") # 当前阶段
covered_stages = Column(JSON, default=list) # 已完成阶段列表
slots = Column(JSON, nullable=False) # 各阶段 slot 信息
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
# Relationships
user = relationship("User", back_populates="memoir_state")
class RefreshToken(Base):
"""刷新令牌表"""
__tablename__ = "refresh_tokens"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
token = Column(String, unique=True, nullable=False, index=True) # 刷新令牌(唯一)
expires_at = Column(DateTime(timezone=True), nullable=False) # 过期时间30天后
created_at = Column(DateTime(timezone=True), default=utc_now)
is_revoked = Column(Boolean, default=False) # 是否已撤销
device_info = Column(String, nullable=True) # 设备信息(用于全设备登出)
# Relationships
user = relationship("User", back_populates="refresh_tokens")
class SmsVerificationCode(Base):
"""短信验证码表"""
__tablename__ = "sms_verification_codes"
id = Column(String, primary_key=True)
phone = Column(String, nullable=False, index=True) # 手机号
code = Column(String, nullable=False) # 6位验证码
purpose = Column(String, nullable=False) # register/login/reset_password/change_phone
is_used = Column(Boolean, default=False) # 是否已使用
is_expired = Column(Boolean, default=False) # 是否已过期
expires_at = Column(DateTime(timezone=True), nullable=False) # 过期时间5分钟后
created_at = Column(DateTime(timezone=True), default=utc_now)
verified_at = Column(DateTime(timezone=True), nullable=True) # 验证时间
ip_address = Column(String, nullable=True) # 请求IP地址
class Order(Base):
"""支付订单表"""
__tablename__ = "orders"
id = Column(String, primary_key=True) # 内部订单号
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
plan_id = Column(String, nullable=False) # 套餐 IDfree / premium
plan_name = Column(String, nullable=False) # 套餐名称
amount = Column(Integer, nullable=False) # 金额(单位:分)
currency = Column(String, default="CNY")
payment_method = Column(String, nullable=False) # wechat / alipay
status = Column(String, default="pending") # pending / paid / failed / cancelled / refunded
trade_no = Column(String, nullable=True, index=True) # 第三方交易号(微信/支付宝)
paid_at = Column(DateTime(timezone=True), nullable=True) # 支付完成时间
created_at = Column(DateTime(timezone=True), default=utc_now)
expired_at = Column(DateTime(timezone=True), nullable=True) # 订单超时时间
# Relationships
user = relationship("User", back_populates="orders")