This commit is contained in:
aaron 2026-05-12 17:05:32 +08:00
parent 2256ded518
commit f89f5ef6e5
32 changed files with 1265 additions and 281 deletions

4
.gitignore vendored
View File

@ -41,6 +41,10 @@ backend/.venv/
venv/ venv/
# Backend runtime data # Backend runtime data
palm_reading.db
*.db
*.sqlite
*.sqlite3
backend/palm_reading.db backend/palm_reading.db
backend/*.db backend/*.db
backend/*.sqlite backend/*.sqlite

View File

@ -8,6 +8,7 @@ OPENAI_API_KEY=
OPENAI_BASE_URL= OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4.1-mini OPENAI_MODEL=gpt-4.1-mini
OPENAI_IMAGE_MODEL=gpt-image-2 OPENAI_IMAGE_MODEL=gpt-image-2
OPENAI_TIMEOUT_SECONDS=180
SHARE_IMAGE_MODE=ai SHARE_IMAGE_MODE=ai
WECHAT_APP_ID= WECHAT_APP_ID=

View File

@ -0,0 +1,102 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from pydantic import ValidationError
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal, get_db
from app.core.security import get_current_user
from app.models.reading import Reading
from app.models.uploaded_image import UploadedImage
from app.models.user import User
from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary
from app.services.image_service import ImageService
from app.services.reading_service import ReadingService
router = APIRouter()
async def generate_reading_task(reading_id: str) -> None:
async with AsyncSessionLocal() as session:
await ReadingService().generate(session, reading_id)
@router.post("", response_model=ReadingDetail, status_code=status.HTTP_201_CREATED)
async def create_reading(
payload: ReadingCreate,
background_tasks: BackgroundTasks,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
input_data = dict(payload.input_data)
image_id = payload.image_id
if payload.reading_type in {"palm", "face"}:
image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == image_id, UploadedImage.user_id == user.id))
image = image_result.scalar_one_or_none()
if image is None:
raise HTTPException(status_code=404, detail="Image not found")
elif payload.reading_type == "bazi":
try:
input_data = BaziInput.model_validate(input_data).model_dump()
except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc
reading = Reading(
user_id=user.id,
reading_type=payload.reading_type,
image_id=image_id,
input_data=input_data,
status="pending",
)
db.add(reading)
await db.flush()
await db.refresh(reading)
await db.commit()
background_tasks.add_task(generate_reading_task, reading.id)
return reading
@router.get("", response_model=list[ReadingSummary])
async def list_readings(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Reading).where(Reading.user_id == user.id).order_by(desc(Reading.created_at)).limit(80)
)
readings = result.scalars().all()
return [
ReadingSummary(
id=reading.id,
reading_type=reading.reading_type,
status=reading.status,
created_at=reading.created_at,
overall_summary=(reading.report_data or {}).get("overall_summary") if reading.report_data else None,
)
for reading in readings
]
@router.get("/{reading_id}", response_model=ReadingDetail)
async def get_reading(reading_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await _get_owned_reading(db, reading_id, user.id)
@router.delete("/{reading_id}")
async def delete_reading(reading_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
reading = await _get_owned_reading(db, reading_id, user.id)
image = None
if reading.image_id:
image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == reading.image_id))
image = image_result.scalar_one_or_none()
await db.delete(reading)
await db.flush()
if image:
ImageService().delete(image.storage_key)
await db.delete(image)
return {"status": "deleted"}
async def _get_owned_reading(db: AsyncSession, reading_id: str, user_id: str) -> Reading:
result = await db.execute(select(Reading).where(Reading.id == reading_id, Reading.user_id == user_id))
reading = result.scalar_one_or_none()
if reading is None:
raise HTTPException(status_code=404, detail="Reading not found")
return reading

View File

@ -70,6 +70,7 @@ async def create_report(
db.add(report) db.add(report)
await db.flush() await db.flush()
await db.refresh(report) await db.refresh(report)
await db.commit()
background_tasks.add_task(generate_report_task, report.id) background_tasks.add_task(generate_report_task, report.id)
return report return report
@ -137,6 +138,7 @@ async def create_share_image_job(
db.add(job) db.add(job)
await db.flush() await db.flush()
await db.refresh(job) await db.refresh(job)
await db.commit()
background_tasks.add_task(generate_share_image_task, job.id) background_tasks.add_task(generate_share_image_task, job.id)
return job return job

View File

@ -17,11 +17,24 @@ async def upload_palm_image(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return await _store_image(file, user, db, "palm")
@router.post("/face", response_model=UploadResponse)
async def upload_face_image(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await _store_image(file, user, db, "face")
async def _store_image(file: UploadFile, user: User, db: AsyncSession, prefix: str) -> UploadResponse:
storage_key, size_bytes, expires_at, quality_check = await ImageService().validate_and_store(file, user.id) storage_key, size_bytes, expires_at, quality_check = await ImageService().validate_and_store(file, user.id)
image = UploadedImage( image = UploadedImage(
user_id=user.id, user_id=user.id,
storage_key=storage_key, storage_key=storage_key,
original_filename=file.filename or "palm", original_filename=file.filename or prefix,
content_type=file.content_type or "application/octet-stream", content_type=file.content_type or "application/octet-stream",
size_bytes=size_bytes, size_bytes=size_bytes,
expires_at=expires_at, expires_at=expires_at,

View File

@ -1,8 +1,9 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, reports, uploads from app.api.v1.endpoints import auth, readings, reports, uploads
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(uploads.router, prefix="/uploads", tags=["uploads"]) api_router.include_router(uploads.router, prefix="/uploads", tags=["uploads"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"]) api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
api_router.include_router(readings.router, prefix="/readings", tags=["readings"])

View File

@ -16,6 +16,7 @@ class Settings(BaseSettings):
openai_base_url: str | None = None openai_base_url: str | None = None
openai_model: str = "gpt-4.1-mini" openai_model: str = "gpt-4.1-mini"
openai_image_model: str = "gpt-image-2" openai_image_model: str = "gpt-image-2"
openai_timeout_seconds: float = 180
share_image_mode: str = "ai" share_image_mode: str = "ai"
wechat_app_id: str | None = None wechat_app_id: str | None = None

View File

@ -25,7 +25,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def init_db() -> None: async def init_db() -> None:
from app.models import palm_report, share_image_job, uploaded_image, user # noqa: F401 from app.models import palm_report, reading, share_image_job, uploaded_image, user # noqa: F401
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)

View File

@ -0,0 +1,25 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Reading(Base):
__tablename__ = "readings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True)
reading_type: Mapped[str] = mapped_column(String(24), index=True)
image_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("uploaded_images.id"), nullable=True)
input_data: Mapped[dict] = mapped_column(JSON, default=dict)
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
report_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="readings")
image = relationship("UploadedImage", back_populates="readings")

View File

@ -20,3 +20,4 @@ class UploadedImage(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
reports = relationship("PalmReport", back_populates="image") reports = relationship("PalmReport", back_populates="image")
readings = relationship("Reading", back_populates="image")

View File

@ -17,3 +17,4 @@ class User(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
reports = relationship("PalmReport", back_populates="user") reports = relationship("PalmReport", back_populates="user")
readings = relationship("Reading", back_populates="user")

View File

@ -0,0 +1,56 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
ReadingType = Literal["palm", "face", "bazi"]
ReadingStatus = Literal["pending", "processing", "completed", "failed"]
class BaziInput(BaseModel):
nickname: str | None = None
gender: str | None = None
calendar_type: Literal["solar", "lunar"] = "solar"
is_leap_month: bool = False
birth_date: str
birth_time: str | None = None
time_unknown: bool = False
birth_place: str | None = None
class ReadingCreate(BaseModel):
reading_type: ReadingType
image_id: str | None = None
input_data: dict = Field(default_factory=dict)
@model_validator(mode="after")
def validate_payload(self):
if self.reading_type in {"palm", "face"} and not self.image_id:
raise ValueError("image_id is required for image readings")
if self.reading_type == "bazi":
BaziInput.model_validate(self.input_data)
return self
class ReadingSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
reading_type: str
status: str
created_at: datetime
overall_summary: str | None = None
class ReadingDetail(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
reading_type: str
status: str
input_data: dict
error_message: str | None = None
report_data: dict | None = None
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,122 @@
import base64
import json
from openai import AsyncOpenAI
from app.core.config import settings
DISCLAIMER = "本报告仅用于娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。"
SYSTEM_PROMPT = (
"你是“赛博先生”,一个面向普通人的娱乐型 AI 命理解读助手。"
"你的风格要像一个会聊天、懂生活的朋友:温和、具体、接地气,有一点玄学仪式感,但不要装神秘。"
"所有表达都必须使用“可能、倾向、适合、提醒你”这类非确定性措辞。"
"禁止给出医疗、心理诊断、投资、职业成败、婚恋结果、寿命、灾祸等确定性判断。"
"不要堆砌专业术语,不要写得像教材;每个结论都要落到生活、学习、事业、关系或近期行动里的具体场景。"
)
REPORT_SCHEMA = {
"name": "reading_report",
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"quality_check": {
"type": "object",
"additionalProperties": False,
"properties": {
"can_analyze": {"type": "boolean"},
"reason": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["can_analyze", "reason", "confidence"],
},
"overall_summary": {"type": "string"},
"dimensions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"observations": {"type": "array", "items": {"type": "string"}},
"interpretation": {"type": "string"},
"confidence": {"type": "number"},
"advice": {"type": "string"},
},
"required": ["name", "observations", "interpretation", "confidence", "advice"],
},
},
"strengths": {"type": "array", "items": {"type": "string"}},
"challenges": {"type": "array", "items": {"type": "string"}},
"suggestions": {"type": "array", "items": {"type": "string"}},
"lucky_keywords": {"type": "array", "items": {"type": "string"}},
"disclaimer": {"type": "string"},
},
"required": [
"quality_check",
"overall_summary",
"dimensions",
"strengths",
"challenges",
"suggestions",
"lucky_keywords",
"disclaimer",
],
},
"strict": True,
}
class BaseAnalyzer:
def __init__(self) -> None:
self.client = (
AsyncOpenAI(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
timeout=settings.openai_timeout_seconds,
)
if settings.openai_api_key
else None
)
async def _create_text_report(self, user_prompt: str) -> dict:
if not self.client:
raise RuntimeError("OpenAI client is not configured")
response = await self.client.responses.create(
model=settings.openai_model,
input=[
{"role": "system", "content": [{"type": "input_text", "text": SYSTEM_PROMPT}]},
{"role": "user", "content": [{"type": "input_text", "text": user_prompt}]},
],
text={"format": {"type": "json_schema", **REPORT_SCHEMA}},
)
data = json.loads(response.output_text)
data["disclaimer"] = DISCLAIMER
return data
async def _create_image_report(self, user_prompt: str, image_bytes: bytes, content_type: str) -> dict:
if not self.client:
raise RuntimeError("OpenAI client is not configured")
image_data = base64.b64encode(image_bytes).decode("ascii")
response = await self.client.responses.create(
model=settings.openai_model,
input=[
{"role": "system", "content": [{"type": "input_text", "text": SYSTEM_PROMPT}]},
{
"role": "user",
"content": [
{"type": "input_text", "text": user_prompt},
{
"type": "input_image",
"image_url": f"data:{content_type};base64,{image_data}",
"detail": "high",
},
],
},
],
text={"format": {"type": "json_schema", **REPORT_SCHEMA}},
)
data = json.loads(response.output_text)
data["disclaimer"] = DISCLAIMER
return data

View File

@ -0,0 +1,91 @@
import json
from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
USER_PROMPT_TEMPLATE = (
"请基于下面这份八字排盘结果,生成中文八字娱乐报告。"
"排盘结果由后端计算,不能自行改动四柱:{chart_json}"
"必须覆盖四柱结构、日主与五行、十神关系、学习成长、事业节奏、关系沟通、近期行动。"
"写作要求:"
"1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论。"
"2. dimensions 中每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。"
"3. 每个 advice 必须是今天或本周能做的小建议。"
"4. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。"
"5. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。"
"6. 如果时辰不详,要诚实降低相关维度 confidence并说明分析会更偏概览。"
)
class BaziAnalyzer(BaseAnalyzer):
async def analyze(self, chart: dict) -> dict:
if not self.client:
return self._mock_report(chart)
prompt = USER_PROMPT_TEMPLATE.format(chart_json=json.dumps(chart, ensure_ascii=False))
try:
return await self._create_text_report(prompt)
except Exception as exc:
report = self._mock_report(chart)
report["quality_check"] = {
"can_analyze": True,
"reason": f"模型响应较慢,已先基于后端排盘生成本地报告:{exc}",
"confidence": 0.62,
}
report["overall_summary"] = "模型响应较慢,先生先根据后端排出的四柱给你一份基础娱乐解读。等模型接口稳定后,可以再次生成更细腻的版本。" + report["overall_summary"]
return report
def _mock_report(self, chart: dict) -> dict:
pillars = chart.get("pillars", {})
day_master = chart.get("day_master", "日主")
return {
"quality_check": {"can_analyze": True, "reason": "mock mode", "confidence": 0.76},
"overall_summary": f"这是一份基于 {pillars.get('year', '')} {pillars.get('month', '')} {pillars.get('day', '')} {pillars.get('time', '')} 的娱乐八字报告。整体看,你的日主线索偏向“{day_master}”,适合先建立稳定节奏,再去放大自己的表达和行动力。近期最重要的是别急着求大结果,先把可执行的小计划跑起来。",
"dimensions": [
{
"name": "四柱结构",
"observations": [f"年柱:{pillars.get('year')}", f"月柱:{pillars.get('month')}", f"日柱:{pillars.get('day')}", f"时柱:{pillars.get('time')}"],
"interpretation": "四柱结构象征一个人的底色、节奏和外部互动方式。放到现实里,你更适合把目标拆成阶段,不必要求自己一步到位。",
"confidence": 0.72,
"advice": "今天先写下一个 7 天目标,只保留最关键的三件事。",
},
{
"name": "日主与五行",
"observations": [f"日主:{day_master}", f"五行线索:{chart.get('wuxing', {})}"],
"interpretation": "日主象征你处理世界的核心方式。你适合找到自己的稳定补给,再去应对学习、工作和关系里的变化。",
"confidence": 0.7,
"advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。",
},
{
"name": "十神关系",
"observations": [f"十神线索:{chart.get('shi_shen', {})}"],
"interpretation": "十神关系可以理解为你和资源、表达、规则、压力之间的互动方式。现实里,适合把压力转成清单,而不是只在脑子里反复想。",
"confidence": 0.66,
"advice": "遇到复杂任务时,先写下“我能控制的部分”。",
},
{
"name": "学习成长",
"observations": ["适合阶段式吸收", "需要稳定反馈"],
"interpretation": "学习上你更适合循序渐进。比起临时爆发,持续复盘更能帮你看到进步。",
"confidence": 0.68,
"advice": "今天结束前写 5 行复盘,记录一个学到的点。",
},
{
"name": "事业节奏",
"observations": ["适合沉淀方法", "不宜频繁自我推翻"],
"interpretation": "事业上适合先做稳定交付,再逐步争取主动权。越是焦虑时,越需要用作品和结果给自己定心。",
"confidence": 0.65,
"advice": "本周整理一个能展示的小成果,哪怕只是文档或案例。",
},
{
"name": "关系沟通",
"observations": ["需要清晰边界", "适合具体表达"],
"interpretation": "关系里你可能会先照顾气氛,但真正有效的是把需求讲清楚。温和不等于什么都自己消化。",
"confidence": 0.63,
"advice": "把一个模糊的不舒服,改写成一句具体请求。",
},
],
"strengths": ["适合长期积累", "能在变化里调整节奏", "对关系氛围比较敏感"],
"challenges": ["压力大时容易想太多", "计划过满会消耗行动力"],
"suggestions": ["生活上固定恢复时间", "学习成长上做短复盘", "事业工作上沉淀可展示成果", "关系沟通上把需求说具体"],
"lucky_keywords": ["阶段推进", "稳定补给", "具体表达"],
"disclaimer": DISCLAIMER,
}

View File

@ -0,0 +1,98 @@
from datetime import datetime
try:
from lunar_python import Lunar, Solar
except ImportError: # pragma: no cover - production image installs the package
Lunar = None
Solar = None
class BaziCalculator:
def calculate(self, payload: dict) -> dict:
birth_date = payload["birth_date"]
time_unknown = bool(payload.get("time_unknown"))
birth_time = payload.get("birth_time") if not time_unknown else "12:00"
hour, minute = self._parse_time(birth_time or "12:00")
year, month, day = self._parse_date(birth_date)
calendar_type = payload.get("calendar_type", "solar")
if Solar and Lunar:
if calendar_type == "lunar":
lunar_month = -month if payload.get("is_leap_month") else month
lunar = Lunar.fromYmdHms(year, lunar_month, day, hour, minute, 0)
solar = lunar.getSolar()
else:
solar = Solar.fromYmdHms(year, month, day, hour, minute, 0)
lunar = solar.getLunar()
eight = lunar.getEightChar()
return {
"calendar_type": calendar_type,
"is_leap_month": bool(payload.get("is_leap_month")),
"birth_date": birth_date,
"birth_time": None if time_unknown else f"{hour:02d}:{minute:02d}",
"time_unknown": time_unknown,
"birth_place": payload.get("birth_place"),
"nickname": payload.get("nickname"),
"gender": payload.get("gender"),
"solar_date": f"{solar.getYear():04d}-{solar.getMonth():02d}-{solar.getDay():02d}",
"lunar_date": f"{lunar.getYear()}{lunar.getMonth()}{lunar.getDay()}",
"pillars": {
"year": eight.getYear(),
"month": eight.getMonth(),
"day": eight.getDay(),
"time": eight.getTime() if not time_unknown else "时辰不详",
},
"wuxing": {
"year": eight.getYearWuXing(),
"month": eight.getMonthWuXing(),
"day": eight.getDayWuXing(),
"time": eight.getTimeWuXing() if not time_unknown else "时辰不详",
},
"shi_shen": {
"year": eight.getYearShiShenGan(),
"month": eight.getMonthShiShenGan(),
"day": "日主",
"time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详",
},
"day_master": eight.getDayGan(),
}
return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown)
def _fallback_chart(self, payload: dict, year: int, month: int, day: int, hour: int, minute: int, time_unknown: bool) -> dict:
stems = "甲乙丙丁戊己庚辛壬癸"
branches = "子丑寅卯辰巳午未申酉戌亥"
seed = year * 372 + month * 31 + day + hour
def pillar(offset: int) -> str:
return stems[(seed + offset) % 10] + branches[(seed + offset) % 12]
return {
"calendar_type": payload.get("calendar_type", "solar"),
"is_leap_month": bool(payload.get("is_leap_month")),
"birth_date": payload["birth_date"],
"birth_time": None if time_unknown else f"{hour:02d}:{minute:02d}",
"time_unknown": time_unknown,
"birth_place": payload.get("birth_place"),
"nickname": payload.get("nickname"),
"gender": payload.get("gender"),
"solar_date": payload["birth_date"],
"lunar_date": "本地未安装 lunar_python暂用娱乐排盘",
"pillars": {
"year": pillar(0),
"month": pillar(13),
"day": pillar(27),
"time": pillar(41) if not time_unknown else "时辰不详",
},
"wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"},
"shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"},
"day_master": pillar(27)[0],
}
def _parse_date(self, value: str) -> tuple[int, int, int]:
parsed = datetime.strptime(value, "%Y-%m-%d")
return parsed.year, parsed.month, parsed.day
def _parse_time(self, value: str) -> tuple[int, int]:
parsed = datetime.strptime(value, "%H:%M")
return parsed.hour, parsed.minute

View File

@ -0,0 +1,75 @@
from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
USER_PROMPT = (
"请分析这张单人正脸照片,生成中文面相娱乐报告。"
"必须先判断照片是否适合分析:是否单人、正脸、五官无遮挡、光线清晰。"
"必须覆盖脸型轮廓、三庭五眼、眉眼神态、鼻唇表达、气色状态、整体气质。"
"写作要求:"
"1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论,要贴近日常生活。"
"2. dimensions 中每个 interpretation 必须关联现实场景,例如学习专注、工作表达、社交沟通、情绪恢复、生活节奏。"
"3. 每个 advice 必须是今天或本周能做的小建议,不要空泛。"
"4. 不评价美丑,不做健康诊断,不判断年龄、种族、身份、财富、婚姻结局。"
"5. 如果照片不够正脸、多人、遮挡或不清晰,要把 quality_check.can_analyze 设为 false并说明原因。"
)
class FaceAnalyzer(BaseAnalyzer):
async def analyze(self, image_bytes: bytes, content_type: str) -> dict:
if not self.client:
return self._mock_report()
return await self._create_image_report(USER_PROMPT, image_bytes, content_type)
def _mock_report(self) -> dict:
return {
"quality_check": {"can_analyze": True, "reason": "mock mode", "confidence": 0.71},
"overall_summary": "这是一份娱乐向面相报告。整体看,你给人的第一感觉偏稳,适合用清楚、温和的表达建立信任。最近如果在学习、工作或关系里需要推进事情,先把状态收拾利落,会更容易进入节奏。",
"dimensions": [
{
"name": "脸型轮廓",
"observations": ["整体轮廓较均衡", "面部线条有稳定感"],
"interpretation": "这类轮廓象征做事方式偏稳,现实里可能更适合长期推进,而不是靠一阵热情冲刺。",
"confidence": 0.68,
"advice": "这周选一个最重要的目标,每天只推进一个小动作。",
},
{
"name": "三庭五眼",
"observations": ["比例观感较协调", "中庭存在一定表达重心"],
"interpretation": "比例协调象征你在计划和表达之间有平衡感。学习或工作中,适合先梳理框架,再输出观点。",
"confidence": 0.64,
"advice": "开会或沟通前,先写下 3 个要点,避免临场发散。",
},
{
"name": "眉眼神态",
"observations": ["眉眼状态较集中", "眼神表达偏内敛"],
"interpretation": "眉眼偏集中,象征观察力不错。你可能容易先看懂气氛,再决定怎么表达。",
"confidence": 0.66,
"advice": "有想法时别只观察,试着先说一个小判断。",
},
{
"name": "鼻唇表达",
"observations": ["表达区域较清晰", "唇部状态偏平和"],
"interpretation": "这象征你适合用稳定语气处理分歧。关系里,不必一次讲完所有委屈,先说最具体的一件事就好。",
"confidence": 0.61,
"advice": "本周沟通需求时,用“我希望下次可以……”开头。",
},
{
"name": "气色状态",
"observations": ["画面气色受光线影响", "整体状态可读性尚可"],
"interpretation": "气色象征当下状态。照片里呈现出的状态提醒你,近期要少一点硬撑,多一点规律恢复。",
"confidence": 0.55,
"advice": "今晚把睡前 30 分钟留给放松,不再继续刷任务。",
},
{
"name": "整体气质",
"observations": ["气质偏克制", "亲和感与边界感并存"],
"interpretation": "整体气质象征你适合走可靠路线。事业或学习里,稳定交付比强行表现更能给你加分。",
"confidence": 0.67,
"advice": "把一个小成果整理出来发给该看到的人。",
},
],
"strengths": ["容易给人可靠感", "适合先观察再行动", "沟通时有稳定气场"],
"challenges": ["想得多时表达会慢半拍", "容易把疲惫藏起来"],
"suggestions": ["生活上先整理作息和精神状态", "学习成长上先列框架再输出", "事业工作上用稳定交付建立信任", "关系沟通上把需求说具体"],
"lucky_keywords": ["稳住气场", "先说重点", "整理状态"],
"disclaimer": DISCLAIMER,
}

View File

@ -1,18 +1,4 @@
import base64 from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
import json
from openai import AsyncOpenAI
from app.core.config import settings
DISCLAIMER = "本报告仅用于娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。"
SYSTEM_PROMPT = (
"你是“赛博先生”,一个面向普通人的娱乐型 AI 命理解读助手。"
"你的风格要像一个会聊天、懂生活的朋友:温和、具体、接地气,有一点玄学仪式感,但不要装神秘。"
"你会根据掌心照片做象征性手相解读,但所有表达都必须使用“可能、倾向、适合、提醒你”这类非确定性措辞。"
"禁止给出医疗、心理诊断、投资、职业成败、婚恋结果、寿命、灾祸等确定性判断。"
"不要堆砌专业术语,不要写得像教材;每个结论都要落到生活、学习、事业、关系或近期行动里的具体场景。"
)
USER_PROMPT_TEMPLATE = ( USER_PROMPT_TEMPLATE = (
"请分析这张{hand_side}手掌照片,生成中文手相报告。" "请分析这张{hand_side}手掌照片,生成中文手相报告。"
@ -28,105 +14,16 @@ USER_PROMPT_TEMPLATE = (
"8. 如果照片不够真实或不够清晰,要诚实降低 confidence并在 reason 里说明。" "8. 如果照片不够真实或不够清晰,要诚实降低 confidence并在 reason 里说明。"
) )
class PalmAnalyzer(BaseAnalyzer):
REPORT_SCHEMA = {
"name": "palm_report",
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"quality_check": {
"type": "object",
"additionalProperties": False,
"properties": {
"can_analyze": {"type": "boolean"},
"reason": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["can_analyze", "reason", "confidence"],
},
"overall_summary": {"type": "string"},
"dimensions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"observations": {"type": "array", "items": {"type": "string"}},
"interpretation": {"type": "string"},
"confidence": {"type": "number"},
"advice": {"type": "string"},
},
"required": ["name", "observations", "interpretation", "confidence", "advice"],
},
},
"strengths": {"type": "array", "items": {"type": "string"}},
"challenges": {"type": "array", "items": {"type": "string"}},
"suggestions": {"type": "array", "items": {"type": "string"}},
"lucky_keywords": {"type": "array", "items": {"type": "string"}},
"disclaimer": {"type": "string"},
},
"required": [
"quality_check",
"overall_summary",
"dimensions",
"strengths",
"challenges",
"suggestions",
"lucky_keywords",
"disclaimer",
],
},
"strict": True,
}
class PalmAnalyzer:
def __init__(self) -> None:
self.client = (
AsyncOpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url)
if settings.openai_api_key
else None
)
async def analyze(self, image_bytes: bytes, content_type: str, hand_side: str) -> dict: async def analyze(self, image_bytes: bytes, content_type: str, hand_side: str) -> dict:
if not self.client: if not self.client:
return self._mock_report(hand_side) return self._mock_report(hand_side)
image_data = base64.b64encode(image_bytes).decode("ascii") return await self._create_image_report(
response = await self.client.responses.create( USER_PROMPT_TEMPLATE.format(hand_side=hand_side),
model=settings.openai_model, image_bytes,
input=[ content_type,
{
"role": "system",
"content": [
{
"type": "input_text",
"text": SYSTEM_PROMPT,
}
],
},
{
"role": "user",
"content": [
{
"type": "input_text",
"text": USER_PROMPT_TEMPLATE.format(hand_side=hand_side),
},
{
"type": "input_image",
"image_url": f"data:{content_type};base64,{image_data}",
"detail": "high",
},
],
},
],
text={"format": {"type": "json_schema", **REPORT_SCHEMA}},
) )
data = json.loads(response.output_text)
data["disclaimer"] = DISCLAIMER
return data
def _mock_report(self, hand_side: str) -> dict: def _mock_report(self, hand_side: str) -> dict:
return { return {

View File

@ -0,0 +1,59 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.reading import Reading
from app.models.uploaded_image import UploadedImage
from app.services.bazi_analyzer import BaziAnalyzer
from app.services.bazi_calculator import BaziCalculator
from app.services.face_analyzer import FaceAnalyzer
from app.services.image_service import ImageService
from app.services.palm_analyzer import PalmAnalyzer
class ReadingService:
async def generate(self, db: AsyncSession, reading_id: str) -> None:
result = await db.execute(select(Reading).where(Reading.id == reading_id))
reading = result.scalar_one()
reading.status = "processing"
await db.flush()
try:
if reading.reading_type == "palm":
data = await self._generate_palm(db, reading)
elif reading.reading_type == "face":
data = await self._generate_face(db, reading)
elif reading.reading_type == "bazi":
data = await self._generate_bazi(reading)
else:
raise ValueError(f"Unsupported reading type: {reading.reading_type}")
reading.report_data = data
reading.status = "failed" if not data["quality_check"]["can_analyze"] else "completed"
reading.error_message = None if reading.status == "completed" else data["quality_check"]["reason"]
except Exception as exc:
reading.status = "failed"
reading.error_message = str(exc)
await db.commit()
async def _generate_palm(self, db: AsyncSession, reading: Reading) -> dict:
image = await self._get_image(db, reading)
image_bytes = ImageService().read_bytes(image.storage_key)
hand_side = reading.input_data.get("hand_side", "unknown")
return await PalmAnalyzer().analyze(image_bytes, image.content_type, hand_side)
async def _generate_face(self, db: AsyncSession, reading: Reading) -> dict:
image = await self._get_image(db, reading)
image_bytes = ImageService().read_bytes(image.storage_key)
return await FaceAnalyzer().analyze(image_bytes, image.content_type)
async def _generate_bazi(self, reading: Reading) -> dict:
chart = BaziCalculator().calculate(reading.input_data)
reading.input_data = {**reading.input_data, "chart": chart}
return await BaziAnalyzer().analyze(chart)
async def _get_image(self, db: AsyncSession, reading: Reading) -> UploadedImage:
result = await db.execute(select(UploadedImage).where(UploadedImage.id == reading.image_id))
image = result.scalar_one_or_none()
if image is None:
raise ValueError("Image not found")
return image

View File

@ -10,5 +10,6 @@ httpx==0.28.1
openai==2.34.0 openai==2.34.0
Pillow==11.1.0 Pillow==11.1.0
PyJWT==2.10.1 PyJWT==2.10.1
lunar_python==1.4.8
pytest==8.3.4 pytest==8.3.4
pytest-asyncio==0.25.2 pytest-asyncio==0.25.2

View File

@ -1,11 +1,13 @@
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.core.database import init_db
from app.main import app from app.main import app
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_anonymous_login_returns_reusable_user_token(): async def test_anonymous_login_returns_reusable_user_token():
await init_db()
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "browser-1"}) first = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "browser-1"})

View File

@ -1,11 +1,12 @@
import pytest import pytest
from app.services.palm_analyzer import DISCLAIMER, PalmAnalyzer from app.services.analyzer_common import DISCLAIMER
from app.services.palm_analyzer import PalmAnalyzer
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mock_report_has_required_shape(monkeypatch): async def test_mock_report_has_required_shape(monkeypatch):
monkeypatch.setattr("app.services.palm_analyzer.settings.openai_api_key", None) monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
analyzer = PalmAnalyzer() analyzer = PalmAnalyzer()
report = await analyzer.analyze(b"fake", "image/jpeg", "left") report = await analyzer.analyze(b"fake", "image/jpeg", "left")

View File

@ -0,0 +1,36 @@
import pytest
from app.services.analyzer_common import DISCLAIMER
from app.services.bazi_analyzer import BaziAnalyzer
from app.services.bazi_calculator import BaziCalculator
from app.services.face_analyzer import FaceAnalyzer
@pytest.mark.asyncio
async def test_face_mock_report_has_required_shape(monkeypatch):
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
report = await FaceAnalyzer().analyze(b"fake", "image/jpeg")
assert report["quality_check"]["can_analyze"] is True
assert len(report["dimensions"]) >= 6
assert report["disclaimer"] == DISCLAIMER
@pytest.mark.asyncio
async def test_bazi_mock_report_has_required_shape(monkeypatch):
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
chart = BaziCalculator().calculate(
{
"calendar_type": "solar",
"birth_date": "1992-08-18",
"birth_time": "09:30",
"time_unknown": False,
"birth_place": "广东深圳",
}
)
report = await BaziAnalyzer().analyze(chart)
assert chart["pillars"]["year"]
assert report["quality_check"]["can_analyze"] is True
assert len(report["dimensions"]) >= 6
assert report["disclaimer"] == DISCLAIMER

View File

@ -0,0 +1,45 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.core.database import init_db
from app.main import app
@pytest.mark.asyncio
async def test_bazi_reading_lifecycle(monkeypatch):
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
await init_db()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "readings-bazi"})
token = auth.json()["access_token"]
headers = {"authorization": f"Bearer {token}"}
created = await client.post(
"/api/v1/readings",
headers=headers,
json={
"reading_type": "bazi",
"input_data": {
"calendar_type": "solar",
"birth_date": "1992-08-18",
"birth_time": "09:30",
"time_unknown": False,
"birth_place": "广东深圳",
},
},
)
assert created.status_code == 201
reading_id = created.json()["id"]
detail = await client.get(f"/api/v1/readings/{reading_id}", headers=headers)
assert detail.status_code == 200
assert detail.json()["reading_type"] == "bazi"
assert detail.json()["status"] in {"pending", "processing", "completed"}
listing = await client.get("/api/v1/readings", headers=headers)
assert listing.status_code == 200
assert any(item["id"] == reading_id for item in listing.json())
deleted = await client.delete(f"/api/v1/readings/{reading_id}", headers=headers)
assert deleted.status_code == 200

Binary file not shown.

5
web/app/archive/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import PalmWebApp from "@/components/PalmWebApp";
export default function ArchivePage() {
return <PalmWebApp initialView="archive" />;
}

5
web/app/bazi/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import PalmWebApp from "@/components/PalmWebApp";
export default function BaziPage() {
return <PalmWebApp initialView="bazi" />;
}

5
web/app/face/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import PalmWebApp from "@/components/PalmWebApp";
export default function FacePage() {
return <PalmWebApp initialView="face" />;
}

View File

@ -33,7 +33,8 @@ body {
} }
button, button,
input { input,
select {
font: inherit; font: inherit;
} }
@ -394,6 +395,58 @@ p {
margin-top: 16px; margin-top: 16px;
} }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 20px;
}
.field,
.checkbox-line {
display: grid;
gap: 8px;
}
.field-wide {
grid-column: 1 / -1;
}
.field span {
color: var(--gold);
font: 700 12px ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.08em;
}
.field input,
.field select {
width: 100%;
min-height: 46px;
border: 1px solid var(--line);
border-radius: 14px;
color: var(--paper);
background: rgba(8, 13, 12, 0.48);
padding: 0 13px;
}
.field input:disabled {
color: rgba(245, 236, 216, 0.42);
}
.checkbox-line {
align-content: end;
grid-template-columns: auto 1fr;
align-items: center;
min-height: 46px;
color: var(--paper-dim);
}
.checkbox-line input {
width: 18px;
height: 18px;
accent-color: var(--gold);
}
.primary-action:disabled { .primary-action:disabled {
opacity: 0.72; opacity: 0.72;
cursor: wait; cursor: wait;
@ -825,6 +878,21 @@ p {
font-size: 14px; font-size: 14px;
} }
.form-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.field-wide {
grid-column: auto;
}
.field input,
.field select {
min-height: 44px;
font-size: 14px;
}
.archive-head { .archive-head {
align-items: flex-start; align-items: flex-start;
} }
@ -900,7 +968,7 @@ p {
left: 12px; left: 12px;
z-index: 30; z-index: 30;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 6px; gap: 6px;
padding: 8px; padding: 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
@ -919,7 +987,7 @@ p {
border-radius: 16px; border-radius: 16px;
color: var(--paper-dim); color: var(--paper-dim);
background: transparent; background: transparent;
font: 700 12px ui-sans-serif, system-ui, sans-serif; font: 700 11px ui-sans-serif, system-ui, sans-serif;
} }
.mobile-tabbar span { .mobile-tabbar span {

View File

@ -1,5 +1,5 @@
import PalmWebApp from "@/components/PalmWebApp"; import PalmWebApp from "@/components/PalmWebApp";
export default function Home() { export default function Home() {
return <PalmWebApp />; return <PalmWebApp initialView="home" />;
} }

5
web/app/palm/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import PalmWebApp from "@/components/PalmWebApp";
export default function PalmPage() {
return <PalmWebApp initialView="palm" />;
}

View File

@ -1,14 +1,16 @@
"use client"; "use client";
import { ChangeEvent, useEffect, useMemo, useState } from "react"; import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
import { import {
Dimension, Dimension,
HandSide, HandSide,
Report, Reading,
ReadingSummary,
ReadingType,
ReportData, ReportData,
ReportSummary,
apiFetch, apiFetch,
ensureToken, ensureToken,
uploadFaceImage,
uploadPalmImage, uploadPalmImage,
} from "@/lib/api"; } from "@/lib/api";
@ -18,140 +20,237 @@ const handText: Record<HandSide, string> = {
unknown: "不确定", unknown: "不确定",
}; };
const statusText: Record<Report["status"], string> = { const statusText: Record<Reading["status"], string> = {
pending: "等待中", pending: "等待中",
processing: "生成中", processing: "生成中",
completed: "已完成", completed: "已完成",
failed: "失败", failed: "失败",
}; };
const services = [ const readingMeta: Record<ReadingType, { name: string; title: string; label: string; description: string }> = {
{ palm: {
id: "palm",
name: "手相", name: "手相",
title: "掌心解读", title: "掌心解读",
label: "PALM READING",
description: "上传掌心照片,生成生活、学习、事业与关系提醒。", description: "上传掌心照片,生成生活、学习、事业与关系提醒。",
status: "已开放",
}, },
{ face: {
id: "face",
name: "面相", name: "面相",
title: "气色与五官", title: "气色与五官",
description: "未来支持面部特征与状态观察,适合做日常运势入口。", label: "FACE READING",
status: "规划中", description: "上传单人正脸照,观察脸型、五官、气色与表达状态。",
}, },
{ bazi: {
id: "bazi",
name: "八字", name: "八字",
title: "生辰格局", title: "生辰格局",
description: "未来支持出生信息推演,沉淀长期个人档案。", label: "BAZI READING",
status: "规划中", description: "填写出生信息,排出四柱后生成生活化八字报告。",
}, },
]; };
export default function PalmWebApp() { type View = "home" | ReadingType | "archive";
const views: View[] = ["home", "palm", "face", "bazi", "archive"];
type BaziForm = {
nickname: string;
gender: string;
calendar_type: "solar" | "lunar";
is_leap_month: boolean;
birth_date: string;
birth_time: string;
time_unknown: boolean;
birth_place: string;
};
const defaultBaziForm: BaziForm = {
nickname: "",
gender: "",
calendar_type: "solar",
is_leap_month: false,
birth_date: "",
birth_time: "12:00",
time_unknown: false,
birth_place: "",
};
export default function PalmWebApp({ initialView = "home" }: { initialView?: View }) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [file, setFile] = useState<File | null>(null); const [palmFile, setPalmFile] = useState<File | null>(null);
const [preview, setPreview] = useState(""); const [faceFile, setFaceFile] = useState<File | null>(null);
const [palmPreview, setPalmPreview] = useState("");
const [facePreview, setFacePreview] = useState("");
const [handSide, setHandSide] = useState<HandSide>("unknown"); const [handSide, setHandSide] = useState<HandSide>("unknown");
const [baziForm, setBaziForm] = useState<BaziForm>(defaultBaziForm);
const [busyText, setBusyText] = useState(""); const [busyText, setBusyText] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [reports, setReports] = useState<ReportSummary[]>([]); const [readings, setReadings] = useState<ReadingSummary[]>([]);
const [activeReport, setActiveReport] = useState<Report | null>(null); const [activeReading, setActiveReading] = useState<Reading | null>(null);
const [activeView, setActiveView] = useState<"home" | "palm" | "archive">("home"); const [activeView, setActiveView] = useState<View>(initialView);
const [activeJobId, setActiveJobId] = useState(""); const [activeJobId, setActiveJobId] = useState("");
const [activeJobType, setActiveJobType] = useState<ReadingType>("palm");
useEffect(() => { useEffect(() => {
setActiveView(readViewFromPath(initialView));
const onPopState = () => setActiveView(readViewFromPath(initialView));
window.addEventListener("popstate", onPopState);
ensureToken() ensureToken()
.then(() => Promise.all([loadReports()])) .then(() => loadReadings())
.then(() => setReady(true)) .then(() => setReady(true))
.catch((err) => { .catch((err) => {
setError(err.message || "初始化失败"); setError(err.message || "初始化失败");
setReady(true); setReady(true);
}); });
}, []);
return () => window.removeEventListener("popstate", onPopState);
}, [initialView]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (preview) URL.revokeObjectURL(preview); if (palmPreview) URL.revokeObjectURL(palmPreview);
if (facePreview) URL.revokeObjectURL(facePreview);
}; };
}, [preview]); }, [palmPreview, facePreview]);
const completedReports = reports.filter((item) => item.status === "completed").length; const completedReadings = readings.filter((item) => item.status === "completed").length;
const latestReport = reports[0]; const latestReading = readings[0];
const hasActiveJob = Boolean(activeJobId); const hasActiveJob = Boolean(activeJobId);
function onPickFile(event: ChangeEvent<HTMLInputElement>) { function pickFile(event: ChangeEvent<HTMLInputElement>, type: "palm" | "face") {
const selected = event.target.files?.[0]; const selected = event.target.files?.[0];
if (!selected) return; if (!selected) return;
setFile(selected);
setError(""); setError("");
if (preview) URL.revokeObjectURL(preview); const url = URL.createObjectURL(selected);
setPreview(URL.createObjectURL(selected)); if (type === "palm") {
if (palmPreview) URL.revokeObjectURL(palmPreview);
setPalmFile(selected);
setPalmPreview(url);
} else {
if (facePreview) URL.revokeObjectURL(facePreview);
setFaceFile(selected);
setFacePreview(url);
}
} }
async function loadReports() { async function loadReadings() {
const data = await apiFetch<ReportSummary[]>("/reports"); const data = await apiFetch<ReadingSummary[]>("/readings");
setReports(data); setReadings(data);
} }
async function startReport() { async function startImageReading(type: "palm" | "face") {
const file = type === "palm" ? palmFile : faceFile;
if (!file) { if (!file) {
setError("请先选择一张清晰的掌心照片。"); setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。");
return; return;
} }
setBusyText("正在接入掌纹照片..."); setBusyText(type === "palm" ? "正在接入掌纹照片..." : "正在接入正脸照片...");
setError(""); setError("");
try { try {
const upload = await uploadPalmImage(file); const upload = type === "palm" ? await uploadPalmImage(file) : await uploadFaceImage(file);
const report = await apiFetch<Report>("/reports", { const reading = await apiFetch<Reading>("/readings", {
method: "POST", method: "POST",
body: JSON.stringify({ image_id: upload.image_id, hand_side: handSide }), body: JSON.stringify({
reading_type: type,
image_id: upload.image_id,
input_data: type === "palm" ? { hand_side: handSide } : { photo_style: "front_single" },
}),
}); });
setActiveReport(report); afterTaskCreated(reading);
setActiveJobId(report.id); if (type === "palm") {
setBusyText(""); setPalmFile(null);
setFile(null); setPalmPreview("");
setPreview(""); } else {
void pollReport(report.id) setFaceFile(null);
.then(() => loadReports()) setFacePreview("");
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败")) }
.finally(() => setActiveJobId(""));
await loadReports();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "生成失败"); setError(err instanceof Error ? err.message : "提交失败");
setBusyText(""); setBusyText("");
} }
} }
async function pollReport(reportId: string) { async function startBaziReading() {
if (!baziForm.birth_date) {
setError("请先填写出生日期。");
return;
}
if (!baziForm.time_unknown && !baziForm.birth_time) {
setError("请选择出生时间,或勾选“不确定时辰”。");
return;
}
setBusyText("正在排出四柱...");
setError("");
try {
const reading = await apiFetch<Reading>("/readings", {
method: "POST",
body: JSON.stringify({
reading_type: "bazi",
input_data: {
...baziForm,
nickname: baziForm.nickname || null,
gender: baziForm.gender || null,
birth_place: baziForm.birth_place || null,
},
}),
});
afterTaskCreated(reading);
} catch (err) {
setError(err instanceof Error ? err.message : "提交失败");
setBusyText("");
}
}
function afterTaskCreated(reading: Reading) {
setActiveReading(reading);
setActiveJobId(reading.id);
setActiveJobType(reading.reading_type);
setBusyText("");
void pollReading(reading.id)
.then(() => loadReadings())
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
.finally(() => setActiveJobId(""));
void loadReadings();
}
async function pollReading(readingId: string) {
for (let i = 0; i < 80; i += 1) { for (let i = 0; i < 80; i += 1) {
const report = await apiFetch<Report>(`/reports/${reportId}`); const reading = await apiFetch<Reading>(`/readings/${readingId}`);
setActiveReport(report); setActiveReading(reading);
if (report.status === "completed") return report; if (reading.status === "completed") return reading;
if (report.status === "failed") throw new Error(report.error_message || "报告生成失败"); if (reading.status === "failed") throw new Error(reading.error_message || "报告生成失败");
await sleep(2200); await sleep(2200);
} }
throw new Error("生成时间较长,请稍后在档案中查看。"); throw new Error("生成时间较长,请稍后在档案中查看。");
} }
async function openReport(reportId: string) { async function openReading(readingId: string) {
setError(""); setError("");
const report = await apiFetch<Report>(`/reports/${reportId}`); const reading = await apiFetch<Reading>(`/readings/${readingId}`);
setActiveReport(report); setActiveReading(reading);
navigate("archive");
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
async function deleteReport(reportId: string) { async function deleteReading(readingId: string) {
await apiFetch(`/reports/${reportId}`, { method: "DELETE" }); await apiFetch(`/readings/${readingId}`, { method: "DELETE" });
if (activeReport?.id === reportId) setActiveReport(null); if (activeReading?.id === readingId) setActiveReading(null);
await loadReports(); await loadReadings();
}
function navigate(view: View) {
setError("");
setActiveView(view);
window.history.pushState(null, "", pathForView(view));
if (view !== "archive") {
setActiveReading(null);
}
window.scrollTo({ top: 0, behavior: "smooth" });
} }
return ( return (
<main className="shell"> <main className="shell">
<header className="app-topbar"> <header className="app-topbar">
<button className="brand compact-button" onClick={() => setActiveView("home")}> <button className="brand compact-button" onClick={() => navigate("home")}>
<span className="seal"></span> <span className="seal"></span>
<span> <span>
<small>CYBER MISTER</small> <small>CYBER MISTER</small>
@ -159,17 +258,19 @@ export default function PalmWebApp() {
</span> </span>
</button> </button>
<nav className="desktop-nav" aria-label="主导航"> <nav className="desktop-nav" aria-label="主导航">
<button className={activeView === "home" ? "active" : ""} onClick={() => setActiveView("home")}></button> {views.map((view) => (
<button className={activeView === "palm" ? "active" : ""} onClick={() => setActiveView("palm")}></button> <button key={view} className={activeView === view ? "active" : ""} onClick={() => navigate(view)}>
<button className={activeView === "archive" ? "active" : ""} onClick={() => setActiveView("archive")}></button> {view === "home" ? "首页" : view === "archive" ? "档案" : readingMeta[view].name}
</button>
))}
</nav> </nav>
</header> </header>
{hasActiveJob ? ( {hasActiveJob ? (
<button className="job-banner" onClick={() => activeJobId && openReport(activeJobId)}> <button className="job-banner" onClick={() => activeJobId && openReading(activeJobId)}>
<span className="pulse-dot" /> <span className="pulse-dot" />
<span></span> <span>{readingMeta[activeJobType].name}</span>
<strong></strong> <strong></strong>
</button> </button>
) : null} ) : null}
@ -178,52 +279,39 @@ export default function PalmWebApp() {
<div className="hero-copy"> <div className="hero-copy">
<p className="eyebrow">AI · · </p> <p className="eyebrow">AI · · </p>
<h1></h1> <h1></h1>
<p></p> <p></p>
</div> </div>
<div className="service-grid"> <div className="service-grid">
{services.map((service) => ( {(["palm", "face", "bazi"] as ReadingType[]).map((type) => (
<button <button key={type} className="service-card" onClick={() => navigate(type)}>
key={service.id} <span></span>
className={`service-card ${service.id !== "palm" ? "muted" : ""}`} <strong>{readingMeta[type].name}</strong>
onClick={() => service.id === "palm" && setActiveView("palm")} <em>{readingMeta[type].title}</em>
> <small>{readingMeta[type].description}</small>
<span>{service.status}</span>
<strong>{service.name}</strong>
<em>{service.title}</em>
<small>{service.description}</small>
</button> </button>
))} ))}
</div> </div>
<div className="quick-row"> <div className="quick-row">
<button className="primary-action" onClick={() => setActiveView("palm")}></button> <button className="primary-action" onClick={() => navigate("palm")}></button>
<button className="ghost-action" onClick={() => setActiveView("archive")}> <button className="ghost-action" onClick={() => navigate(latestReading ? "archive" : "bazi")}>
{latestReport ? "查看最近档案" : "查看档案"} {latestReading ? "查看最近档案" : "试试八字"}
</button> </button>
</div> </div>
</section> </section>
) : null} ) : null}
{activeView === "palm" ? ( {activeView === "palm" ? (
<section className="workspace"> <ImageReadingForm
<div className="upload-card"> type="palm"
<div className="section-label">PALM READING</div> preview={palmPreview}
<h2></h2> busyText={busyText}
<p>线</p> ready={ready}
error={error}
<label className={`drop-zone ${preview ? "has-preview" : ""}`}> onPickFile={(event) => pickFile(event, "palm")}
{preview ? ( onSubmit={() => startImageReading("palm")}
<img src={preview} alt="掌心照片预览" /> extra={
) : (
<span>
<strong></strong>
<small> JPG / PNG / WEBP</small>
</span>
)}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
</label>
<div className="segmented" aria-label="选择左右手"> <div className="segmented" aria-label="选择左右手">
{(["left", "right", "unknown"] as HandSide[]).map((side) => ( {(["left", "right", "unknown"] as HandSide[]).map((side) => (
<button key={side} className={handSide === side ? "active" : ""} onClick={() => setHandSide(side)}> <button key={side} className={handSide === side ? "active" : ""} onClick={() => setHandSide(side)}>
@ -231,27 +319,35 @@ export default function PalmWebApp() {
</button> </button>
))} ))}
</div> </div>
}
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
/>
) : null}
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={startReport}> {activeView === "face" ? (
{busyText || "提交分析"} <ImageReadingForm
</button> type="face"
{error ? <p className="error">{error}</p> : null} preview={facePreview}
</div> busyText={busyText}
ready={ready}
error={error}
onPickFile={(event) => pickFile(event, "face")}
onSubmit={() => startImageReading("face")}
extra={null}
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
/>
) : null}
<aside className="side-panel"> {activeView === "bazi" ? (
<div className="mini-card"> <BaziFormView
<div className="section-label"></div> form={baziForm}
<h3></h3> setForm={setBaziForm}
<p></p> busyText={busyText}
</div> ready={ready}
<div className="mini-card"> error={error}
<div className="stats"> onSubmit={startBaziReading}
<Stat label="累计报告" value={reports.length} /> stats={<ReadingStats total={readings.length} completed={completedReadings} />}
<Stat label="已完成" value={completedReports} /> />
</div>
</div>
</aside>
</section>
) : null} ) : null}
{activeView === "archive" ? ( {activeView === "archive" ? (
@ -261,14 +357,14 @@ export default function PalmWebApp() {
<p className="section-label">MISTER ARCHIVE</p> <p className="section-label">MISTER ARCHIVE</p>
<h2></h2> <h2></h2>
</div> </div>
<button className="ghost-action" onClick={() => setActiveView("palm")}></button> <button className="ghost-action" onClick={() => navigate("home")}></button>
</div> </div>
<div className="report-list"> <div className="report-list">
{reports.length ? ( {readings.length ? (
reports.map((item) => ( readings.map((item) => (
<button key={item.id} className="archive-item" onClick={() => openReport(item.id)}> <button key={item.id} className="archive-item" onClick={() => openReading(item.id)}>
<span> <span>
<strong></strong> <strong>{readingMeta[item.reading_type].name}</strong>
<small>{formatDate(item.created_at)}</small> <small>{formatDate(item.created_at)}</small>
{item.overall_summary ? <b>{item.overall_summary}</b> : null} {item.overall_summary ? <b>{item.overall_summary}</b> : null}
</span> </span>
@ -279,48 +375,188 @@ export default function PalmWebApp() {
<p className="empty"></p> <p className="empty"></p>
)} )}
</div> </div>
{activeReading ? <ReportPanel reading={activeReading} onDelete={() => deleteReading(activeReading.id)} /> : null}
</section> </section>
) : null} ) : null}
{activeReport ? (
<ReportPanel
report={activeReport}
onDelete={() => deleteReport(activeReport.id)}
/>
) : null}
<nav className="mobile-tabbar" aria-label="移动端导航"> <nav className="mobile-tabbar" aria-label="移动端导航">
<button className={activeView === "home" ? "active" : ""} onClick={() => setActiveView("home")}> {views.map((view) => (
<span></span> <button key={view} className={activeView === view ? "active" : ""} onClick={() => navigate(view)}>
<span>{view === "home" ? "⌂" : view === "archive" ? "册" : readingMeta[view].name.slice(0, 1)}</span>
</button> {view === "home" ? "首页" : view === "archive" ? "档案" : readingMeta[view].name}
<button className={activeView === "palm" ? "active" : ""} onClick={() => setActiveView("palm")}> </button>
<span></span> ))}
</button>
<button className={activeView === "archive" ? "active" : ""} onClick={() => setActiveView("archive")}>
<span></span>
</button>
</nav> </nav>
</main> </main>
); );
} }
function ReportPanel({ function ImageReadingForm({
report, type,
onDelete, preview,
busyText,
ready,
error,
extra,
stats,
onPickFile,
onSubmit,
}: { }: {
report: Report; type: "palm" | "face";
onDelete: () => void; preview: string;
busyText: string;
ready: boolean;
error: string;
extra: ReactNode;
stats: ReactNode;
onPickFile: (event: ChangeEvent<HTMLInputElement>) => void;
onSubmit: () => void;
}) { }) {
const data = report.report_data; const isPalm = type === "palm";
return (
<section className="workspace">
<div className="upload-card">
<div className="section-label">{readingMeta[type].label}</div>
<h2>{isPalm ? "上传掌心照片" : "上传正脸照片"}</h2>
<p>{isPalm ? "掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。" : "请上传单人正脸照,五官无遮挡,光线自然清晰。"}</p>
<label className={`drop-zone ${preview ? "has-preview" : ""}`}>
{preview ? (
<img src={preview} alt={isPalm ? "掌心照片预览" : "正脸照片预览"} />
) : (
<span>
<strong></strong>
<small> JPG / PNG / WEBP</small>
</span>
)}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
</label>
{extra}
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
{busyText || "提交分析"}
</button>
{error ? <p className="error">{error}</p> : null}
</div>
<aside className="side-panel">
<div className="mini-card">
<div className="section-label"></div>
<h3></h3>
<p></p>
</div>
<div className="mini-card">{stats}</div>
</aside>
</section>
);
}
function BaziFormView({
form,
setForm,
busyText,
ready,
error,
stats,
onSubmit,
}: {
form: BaziForm;
setForm: (form: BaziForm) => void;
busyText: string;
ready: boolean;
error: string;
stats: ReactNode;
onSubmit: () => void;
}) {
return (
<section className="workspace">
<div className="upload-card">
<div className="section-label">BAZI READING</div>
<h2></h2>
<p></p>
<div className="form-grid">
<label className="field">
<span></span>
<input value={form.nickname} onChange={(event) => setForm({ ...form, nickname: event.target.value })} placeholder="可不填" />
</label>
<label className="field">
<span>/</span>
<select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
<option value=""></option>
<option value="female"></option>
<option value="male"></option>
<option value="other"></option>
</select>
</label>
<label className="field">
<span></span>
<select value={form.calendar_type} onChange={(event) => setForm({ ...form, calendar_type: event.target.value as "solar" | "lunar" })}>
<option value="solar"></option>
<option value="lunar"></option>
</select>
</label>
{form.calendar_type === "lunar" ? (
<label className="checkbox-line">
<input type="checkbox" checked={form.is_leap_month} onChange={(event) => setForm({ ...form, is_leap_month: event.target.checked })} />
</label>
) : null}
<label className="field">
<span></span>
<input type="date" value={form.birth_date} onChange={(event) => setForm({ ...form, birth_date: event.target.value })} />
</label>
<label className="field">
<span></span>
<input type="time" value={form.birth_time} disabled={form.time_unknown} onChange={(event) => setForm({ ...form, birth_time: event.target.value })} />
</label>
<label className="checkbox-line">
<input type="checkbox" checked={form.time_unknown} onChange={(event) => setForm({ ...form, time_unknown: event.target.checked })} />
</label>
<label className="field field-wide">
<span></span>
<input value={form.birth_place} onChange={(event) => setForm({ ...form, birth_place: event.target.value })} placeholder="例如:广东深圳" />
</label>
</div>
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
{busyText || "提交排盘"}
</button>
{error ? <p className="error">{error}</p> : null}
</div>
<aside className="side-panel">
<div className="mini-card">
<div className="section-label"></div>
<h3></h3>
<p>线</p>
</div>
<div className="mini-card">{stats}</div>
</aside>
</section>
);
}
function ReadingStats({ total, completed }: { total: number; completed: number }) {
return (
<div className="stats">
<Stat label="累计报告" value={total} />
<Stat label="已完成" value={completed} />
</div>
);
}
function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () => void }) {
const data = reading.report_data;
const score = useMemo(() => getScore(data), [data]); const score = useMemo(() => getScore(data), [data]);
const type = reading.reading_type;
const handSide = (reading.input_data?.hand_side as HandSide | undefined) || "unknown";
if (!data) { if (!data) {
return ( return (
<section className="report-panel"> <section className="report-panel">
<h3>{statusText[report.status]}</h3> <h3>{statusText[reading.status]}</h3>
<p>{report.error_message || "先生正在整理报告。"}</p> <p>{reading.error_message || "先生正在整理报告。"}</p>
</section> </section>
); );
} }
@ -329,8 +565,11 @@ function ReportPanel({
<section className="report-panel"> <section className="report-panel">
<div className="report-hero"> <div className="report-hero">
<div> <div>
<p className="section-label">PALM REPORT · {handText[report.hand_side]}</p> <p className="section-label">
<h3></h3> {readingMeta[type].label}
{type === "palm" ? ` · ${handText[handSide]}` : ""}
</p>
<h3>{readingMeta[type].name}</h3>
<p>{data.overall_summary}</p> <p>{data.overall_summary}</p>
</div> </div>
<div className="score"> <div className="score">
@ -413,6 +652,16 @@ function formatDate(value: string) {
return value.replace("T", " ").slice(0, 16); return value.replace("T", " ").slice(0, 16);
} }
function readViewFromPath(fallback: View): View {
if (typeof window === "undefined") return "home";
const segment = window.location.pathname.replace(/^\/+/, "").split("/")[0] || "home";
return views.includes(segment as View) ? (segment as View) : fallback;
}
function pathForView(view: View) {
return view === "home" ? "/" : `/${view}`;
}
function sleep(ms: number) { function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }

View File

@ -4,6 +4,7 @@ const TOKEN_KEY = "cyber_mister_token";
const CLIENT_KEY = "cyber_mister_client_id"; const CLIENT_KEY = "cyber_mister_client_id";
export type HandSide = "left" | "right" | "unknown"; export type HandSide = "left" | "right" | "unknown";
export type ReadingType = "palm" | "face" | "bazi";
export type Dimension = { export type Dimension = {
name: string; name: string;
@ -28,24 +29,28 @@ export type ReportData = {
disclaimer: string; disclaimer: string;
}; };
export type Report = { export type Reading = {
id: string; id: string;
reading_type: ReadingType;
status: "pending" | "processing" | "completed" | "failed"; status: "pending" | "processing" | "completed" | "failed";
hand_side: HandSide; input_data: Record<string, unknown>;
error_message?: string | null; error_message?: string | null;
report_data?: ReportData | null; report_data?: ReportData | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
export type ReportSummary = { export type ReadingSummary = {
id: string; id: string;
status: Report["status"]; reading_type: ReadingType;
hand_side: HandSide; status: Reading["status"];
created_at: string; created_at: string;
overall_summary?: string | null; overall_summary?: string | null;
}; };
export type Report = Reading & { hand_side?: HandSide };
export type ReportSummary = ReadingSummary & { hand_side?: HandSide };
export function getStoredToken() { export function getStoredToken() {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
return localStorage.getItem(TOKEN_KEY) || ""; return localStorage.getItem(TOKEN_KEY) || "";
@ -89,11 +94,19 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}): Prom
} }
export async function uploadPalmImage(file: File) { export async function uploadPalmImage(file: File) {
return uploadImage(file, "palm");
}
export async function uploadFaceImage(file: File) {
return uploadImage(file, "face");
}
async function uploadImage(file: File, type: "palm" | "face") {
const token = await ensureToken(); const token = await ensureToken();
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const response = await fetch(`${API_BASE_URL}/uploads/palm`, { const response = await fetch(`${API_BASE_URL}/uploads/${type}`, {
method: "POST", method: "POST",
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
body: formData, body: formData,