268 lines
10 KiB
Python
268 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""跨平台市場活動情報 ORM models。"""
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
Column,
|
||
DateTime,
|
||
Float,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
String,
|
||
Text,
|
||
UniqueConstraint,
|
||
)
|
||
from sqlalchemy.orm import relationship
|
||
|
||
from database.models import Base
|
||
|
||
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
|
||
def taipei_now():
|
||
"""取得台北時間 naive datetime,符合專案 DB 時間規範。"""
|
||
return datetime.now(TAIPEI_TZ).replace(tzinfo=None)
|
||
|
||
|
||
class MarketPlatform(Base):
|
||
"""市場平台設定,例如 MOMO / PChome / Coupang / Shopee。"""
|
||
|
||
__tablename__ = "market_platforms"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||
name = Column(String(120), nullable=False)
|
||
base_url = Column(String(500))
|
||
enabled = Column(Boolean, default=False, nullable=False)
|
||
crawl_policy_json = Column(Text)
|
||
created_at = Column(DateTime, default=taipei_now, nullable=False)
|
||
updated_at = Column(DateTime, default=taipei_now, onupdate=taipei_now, nullable=False)
|
||
|
||
campaigns = relationship("MarketCampaign", back_populates="platform")
|
||
|
||
|
||
class MarketCampaign(Base):
|
||
"""跨平台活動檔期。"""
|
||
|
||
__tablename__ = "market_campaigns"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
platform_code = Column(String(50), ForeignKey("market_platforms.code"), nullable=False, index=True)
|
||
campaign_key = Column(String(200), nullable=False)
|
||
campaign_name = Column(String(500), nullable=False)
|
||
campaign_type = Column(String(80), index=True)
|
||
campaign_url = Column(Text)
|
||
start_at = Column(DateTime)
|
||
end_at = Column(DateTime)
|
||
status = Column(String(30), default="unknown", nullable=False, index=True)
|
||
discovered_at = Column(DateTime, default=taipei_now, nullable=False)
|
||
last_seen_at = Column(DateTime, default=taipei_now, nullable=False)
|
||
metadata_json = Column(Text)
|
||
|
||
platform = relationship("MarketPlatform", back_populates="campaigns")
|
||
snapshots = relationship("MarketCampaignSnapshot", back_populates="campaign")
|
||
products = relationship("MarketCampaignProduct", back_populates="campaign")
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("platform_code", "campaign_key", name="uq_market_campaign_platform_key"),
|
||
Index("idx_market_campaign_status_time", "status", "start_at", "end_at"),
|
||
)
|
||
|
||
|
||
class MarketCampaignSnapshot(Base):
|
||
"""活動頁每次爬取快照。"""
|
||
|
||
__tablename__ = "market_campaign_snapshots"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), nullable=False, index=True)
|
||
batch_id = Column(String(80), nullable=False, index=True)
|
||
crawled_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||
title = Column(String(500))
|
||
hero_text = Column(Text)
|
||
coupon_text = Column(Text)
|
||
raw_discount_text = Column(Text)
|
||
page_hash = Column(String(128), index=True)
|
||
raw_snapshot_path = Column(Text)
|
||
status = Column(String(30), default="success", nullable=False, index=True)
|
||
error_message = Column(Text)
|
||
metadata_json = Column(Text)
|
||
|
||
campaign = relationship("MarketCampaign", back_populates="snapshots")
|
||
|
||
__table_args__ = (
|
||
Index("idx_market_campaign_snapshot_campaign_time", "campaign_id", "crawled_at"),
|
||
)
|
||
|
||
|
||
class MarketCampaignProduct(Base):
|
||
"""活動頁中的平台商品快照主檔。"""
|
||
|
||
__tablename__ = "market_campaign_products"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), nullable=False, index=True)
|
||
platform_code = Column(String(50), nullable=False, index=True)
|
||
platform_product_id = Column(String(200), nullable=False, index=True)
|
||
product_url = Column(Text)
|
||
name = Column(String(500), nullable=False)
|
||
brand = Column(String(200), index=True)
|
||
image_url = Column(Text)
|
||
category_text = Column(String(300), index=True)
|
||
price = Column(Float)
|
||
original_price = Column(Float)
|
||
discount_text = Column(String(200))
|
||
discount_rate = Column(Float)
|
||
coupon_text = Column(Text)
|
||
stock_text = Column(String(200))
|
||
sold_count = Column(Integer)
|
||
rating = Column(Float)
|
||
review_count = Column(Integer)
|
||
rank_position = Column(Integer)
|
||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||
first_seen_at = Column(DateTime, default=taipei_now, nullable=False)
|
||
last_seen_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||
metadata_json = Column(Text)
|
||
|
||
campaign = relationship("MarketCampaign", back_populates="products")
|
||
price_history = relationship("MarketProductPriceHistory", back_populates="market_product")
|
||
matches = relationship("MarketProductMatch", back_populates="market_product")
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint(
|
||
"campaign_id",
|
||
"platform_code",
|
||
"platform_product_id",
|
||
name="uq_market_campaign_product",
|
||
),
|
||
Index("idx_market_product_platform_seen", "platform_code", "last_seen_at"),
|
||
Index("idx_market_product_discount", "discount_rate", "price"),
|
||
)
|
||
|
||
|
||
class MarketProductPriceHistory(Base):
|
||
"""市場商品價格歷史快照。"""
|
||
|
||
__tablename__ = "market_product_price_history"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
market_product_id = Column(Integer, ForeignKey("market_campaign_products.id"), nullable=False, index=True)
|
||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), nullable=False, index=True)
|
||
platform_code = Column(String(50), nullable=False, index=True)
|
||
platform_product_id = Column(String(200), nullable=False, index=True)
|
||
price = Column(Float)
|
||
original_price = Column(Float)
|
||
discount_rate = Column(Float)
|
||
stock_text = Column(String(200))
|
||
sold_count = Column(Integer)
|
||
rank_position = Column(Integer)
|
||
crawled_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||
batch_id = Column(String(80), nullable=False, index=True)
|
||
metadata_json = Column(Text)
|
||
|
||
market_product = relationship("MarketCampaignProduct", back_populates="price_history")
|
||
|
||
__table_args__ = (
|
||
Index("idx_market_price_platform_time", "platform_code", "platform_product_id", "crawled_at"),
|
||
Index("idx_market_price_campaign_time", "campaign_id", "crawled_at"),
|
||
)
|
||
|
||
|
||
class MarketProductMatch(Base):
|
||
"""市場商品與我方 MOMO 商品的比對審核結果。"""
|
||
|
||
__tablename__ = "market_product_matches"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
market_product_id = Column(Integer, ForeignKey("market_campaign_products.id"), nullable=False, index=True)
|
||
momo_product_id = Column(Integer, ForeignKey("products.id"), index=True)
|
||
momo_i_code = Column(String(50), index=True)
|
||
match_score = Column(Float, default=0.0, nullable=False)
|
||
match_status = Column(String(30), default="needs_review", nullable=False, index=True)
|
||
match_reason_json = Column(Text)
|
||
created_at = Column(DateTime, default=taipei_now, nullable=False)
|
||
reviewed_at = Column(DateTime)
|
||
reviewed_by = Column(String(120))
|
||
|
||
market_product = relationship("MarketCampaignProduct", back_populates="matches")
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("market_product_id", "momo_i_code", name="uq_market_product_momo_match"),
|
||
Index("idx_market_match_status_score", "match_status", "match_score"),
|
||
)
|
||
|
||
|
||
class MarketCrawlerRun(Base):
|
||
"""市場情報爬蟲執行紀錄。"""
|
||
|
||
__tablename__ = "market_crawler_runs"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
platform_code = Column(String(50), index=True)
|
||
crawler_name = Column(String(120), nullable=False, index=True)
|
||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), index=True)
|
||
batch_id = Column(String(80), nullable=False, index=True)
|
||
started_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||
finished_at = Column(DateTime)
|
||
status = Column(String(30), default="started", nullable=False, index=True)
|
||
dry_run = Column(Boolean, default=True, nullable=False)
|
||
pages_found = Column(Integer, default=0, nullable=False)
|
||
products_found = Column(Integer, default=0, nullable=False)
|
||
products_changed = Column(Integer, default=0, nullable=False)
|
||
error_count = Column(Integer, default=0, nullable=False)
|
||
error_message = Column(Text)
|
||
metadata_json = Column(Text)
|
||
|
||
__table_args__ = (
|
||
Index("idx_market_crawler_run_platform_time", "platform_code", "started_at"),
|
||
Index("idx_market_crawler_run_status_time", "status", "started_at"),
|
||
)
|
||
|
||
|
||
class MarketAlertReviewQueue(Base):
|
||
"""市場機會與威脅告警的人工審核佇列。"""
|
||
|
||
__tablename__ = "market_alert_review_queue"
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
alert_candidate_id = Column(String(120), nullable=False, unique=True, index=True)
|
||
review_state = Column(String(40), default="draft", nullable=False, index=True)
|
||
priority_lane = Column(String(40), default="watch", nullable=False, index=True)
|
||
threshold_level = Column(String(40), nullable=False, index=True)
|
||
total_score = Column(Float, default=0.0, nullable=False)
|
||
evidence_bundle_id = Column(String(120), nullable=False, index=True)
|
||
dedupe_key = Column(String(240), nullable=False)
|
||
source_batch_id = Column(String(80), nullable=False, index=True)
|
||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), index=True)
|
||
market_product_id = Column(Integer, ForeignKey("market_campaign_products.id"), index=True)
|
||
momo_i_code = Column(String(50), index=True)
|
||
reviewer_identity = Column(String(120))
|
||
review_action = Column(String(60))
|
||
review_reason = Column(Text)
|
||
reviewed_at = Column(DateTime)
|
||
previous_state = Column(String(40))
|
||
next_state = Column(String(40))
|
||
created_at = Column(DateTime, default=taipei_now, nullable=False)
|
||
updated_at = Column(DateTime, default=taipei_now, onupdate=taipei_now, nullable=False)
|
||
metadata_json = Column(Text)
|
||
|
||
__table_args__ = (
|
||
Index(
|
||
"idx_market_alert_review_queue_state_priority",
|
||
"review_state",
|
||
"priority_lane",
|
||
"created_at",
|
||
),
|
||
Index("ux_market_alert_review_queue_dedupe", "dedupe_key", unique=True),
|
||
Index(
|
||
"idx_market_alert_review_queue_bundle",
|
||
"evidence_bundle_id",
|
||
"source_batch_id",
|
||
),
|
||
)
|