diff --git a/category_tree_openapi.json b/category_tree_openapi.json
new file mode 100644
index 00000000..16e13635
--- /dev/null
+++ b/category_tree_openapi.json
@@ -0,0 +1,465 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "化工商品分类树API",
+ "description": "提供SMM和Mysteel化工商品数据的分类树状结构API接口。\n\n## 功能特点\n- 树状数据在服务启动时加载到内存,响应速度快\n- 支持获取完整分类树或按路径查询特定节点\n- SMM数据: 127,509个指标, 最大深度8层\n- Mysteel数据: 272,450个指标, 最大深度10层\n\n## 数据结构\n每个树节点包含:\n- name: 节点名称\n- path: 完整路径(用|分隔)\n- level: 层级深度\n- children: 子节点数组\n- metrics: 该节点下的指标列表(仅叶子节点)\n",
+ "version": "1.0.0",
+ "contact": {
+ "name": "API Support"
+ }
+ },
+ "servers": [
+ {
+ "url": "http://localhost:18827",
+ "description": "本地开发服务器"
+ },
+ {
+ "url": "http://222.128.1.157:18827",
+ "description": "生产服务器"
+ }
+ ],
+ "tags": [
+ {
+ "name": "分类树",
+ "description": "分类树状结构相关接口"
+ }
+ ],
+ "paths": {
+ "/api/category-tree": {
+ "get": {
+ "tags": [
+ "分类树"
+ ],
+ "summary": "获取完整分类树",
+ "description": "获取指定数据源的完整分类树状结构。\n\n## 使用场景\n- 前端树形组件初始化\n- 构建完整的分类导航\n- 级联选择器数据源\n\n## 注意事项\n- SMM树约53MB,Mysteel树约152MB\n- 建议前端实现懒加载或缓存策略\n- 响应时间取决于网络带宽\n",
+ "operationId": "getCategoryTree",
+ "parameters": [
+ {
+ "name": "source",
+ "in": "query",
+ "description": "数据源类型",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "SMM",
+ "Mysteel"
+ ]
+ },
+ "example": "SMM"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功返回分类树",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CategoryTreeResponse"
+ },
+ "examples": {
+ "SMM示例": {
+ "value": {
+ "source": "SMM",
+ "total_metrics": 127509,
+ "tree": [
+ {
+ "name": "农业食品农资",
+ "path": "农业食品农资",
+ "level": 1,
+ "children": [
+ {
+ "name": "饲料",
+ "path": "农业食品农资|饲料",
+ "level": 2,
+ "children": [],
+ "metrics": []
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "Mysteel示例": {
+ "value": {
+ "source": "Mysteel",
+ "total_metrics": 272450,
+ "tree": [
+ {
+ "name": "钢铁产业",
+ "path": "钢铁产业",
+ "level": 1,
+ "children": []
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "未找到指定数据源",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ },
+ "example": {
+ "detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "服务器内部错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/category-tree/node": {
+ "get": {
+ "tags": [
+ "分类树"
+ ],
+ "summary": "获取特定节点及其子树",
+ "description": "根据路径获取树中的特定节点及其所有子节点。\n\n## 使用场景\n- 懒加载:用户点击节点时动态加载子节点\n- 子树查询:获取某个分类下的所有数据\n- 面包屑导航:根据路径定位节点\n\n## 路径格式\n使用竖线(|)分隔层级,例如:\n- 一级: \"钴\"\n- 二级: \"钴|钴化合物\"\n- 三级: \"钴|钴化合物|硫酸钴\"\n",
+ "operationId": "getCategoryTreeNode",
+ "parameters": [
+ {
+ "name": "path",
+ "in": "query",
+ "description": "节点完整路径,用竖线(|)分隔",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "example": "钴|钴化合物|硫酸钴"
+ },
+ {
+ "name": "source",
+ "in": "query",
+ "description": "数据源类型",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "SMM",
+ "Mysteel"
+ ]
+ },
+ "example": "SMM"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功返回节点数据",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TreeNode"
+ },
+ "example": {
+ "name": "硫酸钴",
+ "path": "钴|钴化合物|硫酸钴",
+ "level": 3,
+ "children": [
+ {
+ "name": "产量",
+ "path": "钴|钴化合物|硫酸钴|产量",
+ "level": 4,
+ "children": [],
+ "metrics": [
+ {
+ "metric_id": "12345",
+ "metric_name": "SMM中国硫酸钴月度产量",
+ "source": "SMM",
+ "frequency": "月",
+ "unit": "吨",
+ "description": ""
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "未找到指定路径的节点或数据源",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ },
+ "examples": {
+ "节点不存在": {
+ "value": {
+ "detail": "未找到路径 '钴|不存在的节点' 对应的节点"
+ }
+ },
+ "数据源不存在": {
+ "value": {
+ "detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
+ }
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "服务器内部错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "CategoryTreeResponse": {
+ "type": "object",
+ "description": "分类树响应对象",
+ "required": [
+ "source",
+ "total_metrics",
+ "tree"
+ ],
+ "properties": {
+ "source": {
+ "type": "string",
+ "description": "数据源名称",
+ "enum": [
+ "SMM",
+ "Mysteel"
+ ],
+ "example": "SMM"
+ },
+ "total_metrics": {
+ "type": "integer",
+ "description": "总指标数量",
+ "example": 127509
+ },
+ "tree": {
+ "type": "array",
+ "description": "树的根节点列表",
+ "items": {
+ "$ref": "#/components/schemas/TreeNode"
+ }
+ }
+ }
+ },
+ "TreeNode": {
+ "type": "object",
+ "description": "树节点对象",
+ "required": [
+ "name",
+ "path",
+ "level"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "节点名称",
+ "example": "钴"
+ },
+ "path": {
+ "type": "string",
+ "description": "节点完整路径,用竖线分隔",
+ "example": "钴|钴化合物|硫酸钴"
+ },
+ "level": {
+ "type": "integer",
+ "description": "节点层级深度(从1开始)",
+ "minimum": 1,
+ "example": 3
+ },
+ "children": {
+ "type": "array",
+ "description": "子节点列表",
+ "items": {
+ "$ref": "#/components/schemas/TreeNode"
+ }
+ },
+ "metrics": {
+ "type": "array",
+ "description": "该节点下的指标列表(通常只有叶子节点有)",
+ "items": {
+ "$ref": "#/components/schemas/TreeMetric"
+ }
+ }
+ }
+ },
+ "TreeMetric": {
+ "type": "object",
+ "description": "树节点中的指标信息",
+ "required": [
+ "metric_id",
+ "metric_name",
+ "source",
+ "frequency",
+ "unit"
+ ],
+ "properties": {
+ "metric_id": {
+ "type": "string",
+ "description": "指标唯一ID",
+ "example": "12345"
+ },
+ "metric_name": {
+ "type": "string",
+ "description": "指标名称",
+ "example": "SMM中国硫酸钴月度产量"
+ },
+ "source": {
+ "type": "string",
+ "description": "数据源",
+ "enum": [
+ "SMM",
+ "Mysteel"
+ ],
+ "example": "SMM"
+ },
+ "frequency": {
+ "type": "string",
+ "description": "数据频率",
+ "enum": [
+ "日",
+ "周",
+ "月"
+ ],
+ "example": "月"
+ },
+ "unit": {
+ "type": "string",
+ "description": "指标单位",
+ "example": "吨"
+ },
+ "description": {
+ "type": "string",
+ "description": "指标描述",
+ "example": ""
+ }
+ }
+ },
+ "ErrorResponse": {
+ "type": "object",
+ "description": "错误响应对象",
+ "required": [
+ "detail"
+ ],
+ "properties": {
+ "detail": {
+ "type": "string",
+ "description": "错误详细信息",
+ "example": "未找到数据源 'XXX' 的树状数据"
+ }
+ }
+ }
+ },
+ "examples": {
+ "SMM完整树示例": {
+ "summary": "SMM完整树结构示例",
+ "value": {
+ "source": "SMM",
+ "total_metrics": 127509,
+ "tree": [
+ {
+ "name": "农业食品农资",
+ "path": "农业食品农资",
+ "level": 1,
+ "children": [
+ {
+ "name": "饲料",
+ "path": "农业食品农资|饲料",
+ "level": 2,
+ "children": []
+ }
+ ]
+ },
+ {
+ "name": "小金属",
+ "path": "小金属",
+ "level": 1,
+ "children": [
+ {
+ "name": "钴",
+ "path": "小金属|钴",
+ "level": 2,
+ "children": [
+ {
+ "name": "钴化合物",
+ "path": "小金属|钴|钴化合物",
+ "level": 3,
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "Mysteel完整树示例": {
+ "summary": "Mysteel完整树结构示例",
+ "value": {
+ "source": "Mysteel",
+ "total_metrics": 272450,
+ "tree": [
+ {
+ "name": "钢铁产业",
+ "path": "钢铁产业",
+ "level": 1,
+ "children": [
+ {
+ "name": "原材料",
+ "path": "钢铁产业|原材料",
+ "level": 2,
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "节点查询示例": {
+ "summary": "节点查询返回示例",
+ "value": {
+ "name": "钴化合物",
+ "path": "小金属|钴|钴化合物",
+ "level": 3,
+ "children": [
+ {
+ "name": "硫酸钴",
+ "path": "小金属|钴|钴化合物|硫酸钴",
+ "level": 4,
+ "metrics": [
+ {
+ "metric_id": "12345",
+ "metric_name": "SMM中国硫酸钴月度产量",
+ "source": "SMM",
+ "frequency": "月",
+ "unit": "吨",
+ "description": ""
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/database_migration.sql b/database_migration.sql
deleted file mode 100644
index 02b6884c..00000000
--- a/database_migration.sql
+++ /dev/null
@@ -1,427 +0,0 @@
--- ============================================
--- 订阅支付系统数据库迁移 SQL
--- 版本: v2.0.0
--- 日期: 2025-11-19
--- ============================================
-
--- ============================================
--- 第一步: 备份现有数据
--- ============================================
-
--- 创建备份表
-CREATE TABLE IF NOT EXISTS user_subscriptions_backup AS SELECT * FROM user_subscriptions;
-CREATE TABLE IF NOT EXISTS payment_orders_backup AS SELECT * FROM payment_orders;
-CREATE TABLE IF NOT EXISTS subscription_plans_backup AS SELECT * FROM subscription_plans;
-
--- ============================================
--- 第二步: 删除旧表(先删除外键依赖的表)
--- ============================================
-
-DROP TABLE IF EXISTS subscription_upgrades; -- 删除升级表,不再使用
-DROP TABLE IF EXISTS promo_code_usage; -- 暂时删除,稍后重建
-DROP TABLE IF EXISTS payment_orders; -- 删除旧订单表
-DROP TABLE IF EXISTS user_subscriptions; -- 删除旧订阅表
-DROP TABLE IF EXISTS subscription_plans; -- 删除旧套餐表
-
--- ============================================
--- 第三步: 创建新表结构
--- ============================================
-
--- 1. 订阅套餐表(重构)
-CREATE TABLE subscription_plans (
- id INT PRIMARY KEY AUTO_INCREMENT,
- plan_code VARCHAR(20) NOT NULL UNIQUE COMMENT '套餐代码: pro, max',
- plan_name VARCHAR(50) NOT NULL COMMENT '套餐名称: Pro专业版, Max旗舰版',
- description TEXT COMMENT '套餐描述',
- features JSON COMMENT '功能列表',
-
- -- 价格配置(所有周期价格)
- price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '月付价格',
- price_quarterly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '季付价格(3个月)',
- price_semiannual DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '半年付价格(6个月)',
- price_yearly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '年付价格(12个月)',
-
- -- 状态字段
- is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
- display_order INT DEFAULT 0 COMMENT '展示顺序',
-
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-
- INDEX idx_plan_code (plan_code),
- INDEX idx_active_order (is_active, display_order)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅套餐配置表';
-
-
--- 2. 用户订阅记录表(重构)
-CREATE TABLE user_subscriptions (
- id INT PRIMARY KEY AUTO_INCREMENT,
- user_id INT NOT NULL COMMENT '用户ID',
- subscription_id VARCHAR(32) UNIQUE NOT NULL COMMENT '订阅ID(唯一标识)',
-
- -- 订阅基本信息
- plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码: pro, max, free',
- billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期: monthly, quarterly, semiannual, yearly',
-
- -- 订阅时间
- start_date DATETIME NOT NULL COMMENT '订阅开始时间',
- end_date DATETIME NOT NULL COMMENT '订阅结束时间',
-
- -- 订阅状态
- status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active(有效), expired(已过期), cancelled(已取消)',
- is_current BOOLEAN DEFAULT FALSE COMMENT '是否为当前生效的订阅',
-
- -- 支付信息
- payment_order_id INT COMMENT '关联的支付订单ID',
- paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '实际支付金额',
- original_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '原价',
- discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
-
- -- 订阅类型
- subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
- previous_subscription_id VARCHAR(32) COMMENT '上一个订阅ID(续费时记录)',
-
- -- 自动续费
- auto_renew BOOLEAN DEFAULT FALSE COMMENT '是否自动续费',
-
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-
- INDEX idx_user_id (user_id),
- INDEX idx_subscription_id (subscription_id),
- INDEX idx_user_current (user_id, is_current),
- INDEX idx_status (status),
- INDEX idx_end_date (end_date)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
-
-
--- 3. 支付订单表(重构)
-CREATE TABLE payment_orders (
- id INT PRIMARY KEY AUTO_INCREMENT,
- order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
- user_id INT NOT NULL COMMENT '用户ID',
-
- -- 订阅信息
- plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码',
- billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期',
- subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
-
- -- 价格信息
- original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
- discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
- final_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额',
-
- -- 优惠码
- promo_code_id INT COMMENT '优惠码ID',
- promo_code VARCHAR(50) COMMENT '优惠码',
-
- -- 支付信息
- payment_method VARCHAR(20) DEFAULT 'wechat' COMMENT '支付方式: wechat, alipay',
- payment_channel VARCHAR(50) COMMENT '支付渠道详情',
- transaction_id VARCHAR(64) COMMENT '第三方交易号',
- qr_code_url TEXT COMMENT '支付二维码URL',
-
- -- 订单状态
- status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending(待支付), paid(已支付), expired(已过期), cancelled(已取消)',
-
- -- 时间信息
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- paid_at TIMESTAMP NULL COMMENT '支付时间',
- expired_at TIMESTAMP NULL COMMENT '过期时间',
-
- -- 备注
- remark TEXT COMMENT '备注信息',
-
- INDEX idx_order_no (order_no),
- INDEX idx_user_id (user_id),
- INDEX idx_status (status),
- INDEX idx_created_at (created_at)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
-
-
--- 4. 优惠码使用记录表(重建)
-CREATE TABLE promo_code_usage (
- id INT PRIMARY KEY AUTO_INCREMENT,
- promo_code_id INT NOT NULL,
- user_id INT NOT NULL,
- order_id INT NOT NULL,
- discount_amount DECIMAL(10,2) NOT NULL COMMENT '实际优惠金额',
- used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-
- INDEX idx_promo_code (promo_code_id),
- INDEX idx_user_id (user_id),
- INDEX idx_order_id (order_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
-
-
--- ============================================
--- 第四步: 插入初始数据
--- ============================================
-
--- 插入套餐数据
-INSERT INTO subscription_plans (
- plan_code,
- plan_name,
- description,
- price_monthly,
- price_quarterly,
- price_semiannual,
- price_yearly,
- features,
- display_order,
- is_active
-) VALUES
-(
- 'pro',
- 'Pro 专业版',
- '为专业投资者打造,解锁高级分析功能',
- 299.00,
- 799.00,
- 1499.00,
- 2699.00,
- JSON_ARRAY(
- '新闻信息流',
- '历史事件对比',
- '事件传导链分析(AI)',
- '事件-相关标的分析',
- '相关概念展示',
- 'AI复盘功能',
- '企业概览',
- '个股深度分析(AI) - 50家/月',
- '高效数据筛选工具',
- '概念中心(548大概念)',
- '历史时间轴查询 - 100天',
- '涨停板块数据分析',
- '个股涨停分析'
- ),
- 1,
- TRUE
-),
-(
- 'max',
- 'Max 旗舰版',
- '旗舰级体验,无限制使用所有功能',
- 599.00,
- 1599.00,
- 2999.00,
- 5399.00,
- JSON_ARRAY(
- '全部 Pro 版功能',
- '板块深度分析(AI)',
- '个股深度分析(AI) - 无限制',
- '历史时间轴查询 - 无限制',
- '概念高频更新',
- '优先客服支持',
- '独家功能抢先体验'
- ),
- 2,
- TRUE
-);
-
-
--- ============================================
--- 第五步: 数据迁移(可选)
--- ============================================
-
--- 如果需要迁移旧数据,取消以下注释:
-
-/*
--- 迁移旧的用户订阅数据
-INSERT INTO user_subscriptions (
- user_id,
- subscription_id,
- plan_code,
- billing_cycle,
- start_date,
- end_date,
- status,
- is_current,
- paid_amount,
- original_price,
- subscription_type,
- auto_renew,
- created_at
-)
-SELECT
- user_id,
- CONCAT('SUB_', id, '_', UNIX_TIMESTAMP(NOW())), -- 生成订阅ID
- subscription_type, -- 将 subscription_type 映射为 plan_code
- COALESCE(billing_cycle, 'yearly'), -- 默认年付
- COALESCE(start_date, NOW()),
- COALESCE(end_date, DATE_ADD(NOW(), INTERVAL 365 DAY)),
- subscription_status,
- TRUE, -- 设为当前订阅
- 0, -- 旧数据没有支付金额,设为0
- 0, -- 旧数据没有原价,设为0
- 'new', -- 默认为新购
- COALESCE(auto_renewal, FALSE),
- created_at
-FROM user_subscriptions_backup
-WHERE subscription_type IN ('pro', 'max'); -- 只迁移付费用户
-*/
-
--- ============================================
--- 第六步: 创建免费订阅记录(为所有用户)
--- ============================================
-
--- 为所有现有用户创建免费订阅记录(如果没有付费订阅)
-/*
-INSERT INTO user_subscriptions (
- user_id,
- subscription_id,
- plan_code,
- billing_cycle,
- start_date,
- end_date,
- status,
- is_current,
- paid_amount,
- original_price,
- subscription_type
-)
-SELECT
- id AS user_id,
- CONCAT('FREE_', id, '_', UNIX_TIMESTAMP(NOW())),
- 'free',
- 'monthly',
- NOW(),
- '2099-12-31 23:59:59', -- 免费版永久有效
- 'active',
- TRUE,
- 0,
- 0,
- 'new'
-FROM user
-WHERE id NOT IN (
- SELECT DISTINCT user_id FROM user_subscriptions WHERE plan_code IN ('pro', 'max')
-);
-*/
-
--- ============================================
--- 第七步: 验证数据完整性
--- ============================================
-
--- 检查套餐数据
-SELECT * FROM subscription_plans;
-
--- 检查用户订阅数据
-SELECT
- plan_code,
- COUNT(*) as user_count,
- SUM(CASE WHEN is_current = TRUE THEN 1 ELSE 0 END) as current_count
-FROM user_subscriptions
-GROUP BY plan_code;
-
--- 检查支付订单数据
-SELECT
- status,
- COUNT(*) as order_count,
- SUM(final_amount) as total_amount
-FROM payment_orders
-GROUP BY status;
-
--- ============================================
--- 第八步: 添加外键约束(可选)
--- ============================================
-
--- 注意: 只有在确认 users 表存在且数据完整时才执行
-
--- ALTER TABLE user_subscriptions
--- ADD CONSTRAINT fk_user_subscriptions_user
--- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-
--- ALTER TABLE payment_orders
--- ADD CONSTRAINT fk_payment_orders_user
--- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-
--- ALTER TABLE payment_orders
--- ADD CONSTRAINT fk_payment_orders_promo
--- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL;
-
--- ALTER TABLE promo_code_usage
--- ADD CONSTRAINT fk_promo_usage_promo
--- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE;
-
--- ALTER TABLE promo_code_usage
--- ADD CONSTRAINT fk_promo_usage_user
--- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-
--- ALTER TABLE promo_code_usage
--- ADD CONSTRAINT fk_promo_usage_order
--- FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE;
-
-
--- ============================================
--- 第九步: 创建测试数据(开发环境)
--- ============================================
-
--- 插入测试优惠码
-INSERT INTO promo_codes (
- code,
- description,
- discount_type,
- discount_value,
- applicable_plans,
- applicable_cycles,
- max_total_uses,
- max_uses_per_user,
- valid_from,
- valid_until,
- is_active
-) VALUES
-(
- 'WELCOME2025',
- '2025新用户专享',
- 'percentage',
- 20.00,
- NULL, -- 适用所有套餐
- NULL, -- 适用所有周期
- 1000,
- 1,
- NOW(),
- DATE_ADD(NOW(), INTERVAL 90 DAY),
- TRUE
-),
-(
- 'YEAR2025',
- '年付专享',
- 'percentage',
- 10.00,
- NULL,
- JSON_ARRAY('yearly'), -- 仅适用年付
- 500,
- 1,
- NOW(),
- DATE_ADD(NOW(), INTERVAL 365 DAY),
- TRUE
-),
-(
- 'TESTCODE',
- '测试优惠码 - 固定减100元',
- 'fixed_amount',
- 100.00,
- NULL,
- NULL,
- 100,
- 1,
- NOW(),
- DATE_ADD(NOW(), INTERVAL 30 DAY),
- TRUE
-);
-
-
--- ============================================
--- 迁移完成提示
--- ============================================
-
-SELECT '===================================' AS '';
-SELECT '数据库迁移完成!' AS '状态';
-SELECT '===================================' AS '';
-SELECT '请检查以下数据:' AS '提示';
-SELECT '1. subscription_plans 表是否有2条记录 (pro, max)' AS '';
-SELECT '2. user_subscriptions 表数据是否正确' AS '';
-SELECT '3. payment_orders 表结构是否正确' AS '';
-SELECT '4. 备份表 (*_backup) 已创建' AS '';
-SELECT '===================================' AS '';
-SELECT '下一步: 更新后端代码 (app.py, models.py)' AS '';
-SELECT '===================================' AS '';
diff --git a/index.pug b/index.pug
deleted file mode 100644
index 188f1628..00000000
--- a/index.pug
+++ /dev/null
@@ -1,339 +0,0 @@
-extends layouts/layout
-block content
- +header(true, false, false)
-
- // hero
-
-
-
-
-
智能舆情分析系统
-
-
基于金融领域微调的大语言模型,7×24小时不间断对舆情数据进行深度挖掘和分析,对历史事件进行复盘,关联相关标的,为投资决策提供前瞻性的智能洞察。
-
-
-
-
-
-
- img(class="w-5" src=require('Images/clock.svg') alt="")
-
-
实时数据分析
-
-
-
-
-
- img(class="w-5" src=require('Images/floor.svg') alt="")
-
-
低延迟推理
-
-
-
-
-
-
- video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline)
-
-
-
-
-
- // details
-
-
-
-
-
-
99%
-
金融数据理解准确率
-
基于金融领域深度微调的大语言模型,精准理解市场动态和舆情变化。
-
-
- img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-1.png') alt="")
-
-
-
-
-
24/7
-
全天候舆情监控
-
7×24小时不间断监控市场舆情,第一时间捕捉关键信息。
-
-
- img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-2.png') alt="")
-
-
-
-
- img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-3.png') alt="")
-
-
-
深度模型微调
-
针对金融领域数据进行专业化模型训练和优化。
-
-
-
-
- img(class="w-full" src=require('Images/details-pic-4.png') alt="")
-
-
-
-
- img(class="w-4" src=require('Images/lightning.svg') alt="")
-
-
<100ms
-
-
低延迟推理系统
-
毫秒级响应速度,实时处理海量舆情数据。
-
-
-
-
- img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-5.png') alt="")
-
-
-
历史复盘
-
对历史事件进行深度复盘分析,关联标的,辅助投资决策。
-
-
-
-
-
- // features
-
-
-
-
核心功能
-
我们能做什么?
-
基于AI的舆情分析系统,深度挖掘市场动态,为投资决策提供实时智能洞察。
-
-
-
-
- img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-1.png') alt="")
-
-
-
舆情数据挖掘
-
实时采集和分析全网金融舆情,捕捉市场情绪变化。
-
-
-
-
- img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-2.png') alt="")
-
-
-
智能事件关联
-
自动关联相关标的和历史事件,构建完整的信息图谱。
-
-
-
-
- img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-3.png') alt="")
-
-
-
历史复盘
-
深度复盘历史事件走势,洞察关键节点与转折,为投资决策提供经验参考。
-
-
-
-
- img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-4.png') alt="")
-
-
-
专精金融的AI聊天
-
基于金融领域深度训练的智能对话助手,即时解答市场问题,提供专业投资建议。
-
-
-
-
-
-
- // pricing
-
- include includes/start
-
- +footer(true)
\ No newline at end of file
diff --git a/new_subscription_logic.py b/new_subscription_logic.py
deleted file mode 100644
index 465f72ce..00000000
--- a/new_subscription_logic.py
+++ /dev/null
@@ -1,631 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-新版订阅支付系统核心逻辑
-版本: v2.0.0
-日期: 2025-11-19
-
-核心改进:
-1. 续费价格与新购价格完全一致
-2. 不再计算剩余价值折算
-3. 逻辑简化,易于维护
-"""
-
-from datetime import datetime, timedelta
-from decimal import Decimal
-import json
-import random
-
-
-# ============================================
-# 辅助函数
-# ============================================
-
-def beijing_now():
- """获取北京时间"""
- from datetime import timezone, timedelta
- utc_now = datetime.now(timezone.utc)
- beijing_time = utc_now.astimezone(timezone(timedelta(hours=8)))
- return beijing_time.replace(tzinfo=None)
-
-
-def generate_order_no(user_id):
- """生成订单号"""
- timestamp = int(beijing_now().timestamp() * 1000000)
- random_suffix = random.randint(1000, 9999)
- return f"{timestamp}{user_id:04d}{random_suffix}"
-
-
-def generate_subscription_id():
- """生成订阅ID"""
- timestamp = int(beijing_now().timestamp() * 1000)
- random_suffix = random.randint(10000, 99999)
- return f"SUB_{timestamp}_{random_suffix}"
-
-
-# ============================================
-# 核心业务逻辑
-# ============================================
-
-def calculate_subscription_price(plan_code, billing_cycle, promo_code=None, user_id=None, db_session=None):
- """
- 计算订阅价格(新购和续费价格完全一致)
-
- Args:
- plan_code: 套餐代码 (pro/max)
- billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
- promo_code: 优惠码(可选)
- user_id: 用户ID(可选,用于优惠码验证)
- db_session: 数据库会话(可选)
-
- Returns:
- dict: {
- 'success': True/False,
- 'plan_code': 'pro',
- 'plan_name': 'Pro 专业版',
- 'billing_cycle': 'yearly',
- 'original_price': 2699.00,
- 'discount_amount': 0,
- 'final_amount': 2699.00,
- 'promo_code': None,
- 'promo_error': None,
- 'error': None # 如果有错误
- }
- """
- from models import SubscriptionPlan, PromoCode # 需要在实际使用时导入
-
- try:
- # 1. 查询套餐
- plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
- if not plan:
- return {
- 'success': False,
- 'error': f'套餐 {plan_code} 不存在或已下架'
- }
-
- # 2. 获取对应周期的价格
- price_field_map = {
- 'monthly': 'price_monthly',
- 'quarterly': 'price_quarterly',
- 'semiannual': 'price_semiannual',
- 'yearly': 'price_yearly'
- }
-
- price_field = price_field_map.get(billing_cycle)
- if not price_field:
- return {
- 'success': False,
- 'error': f'不支持的计费周期: {billing_cycle}'
- }
-
- original_price = getattr(plan, price_field, None)
- if original_price is None or original_price <= 0:
- return {
- 'success': False,
- 'error': f'{billing_cycle} 周期价格未配置'
- }
-
- original_price = float(original_price)
-
- # 3. 构建基础结果
- result = {
- 'success': True,
- 'plan_code': plan_code,
- 'plan_name': plan.plan_name,
- 'billing_cycle': billing_cycle,
- 'original_price': original_price,
- 'discount_amount': 0.0,
- 'final_amount': original_price,
- 'promo_code': None,
- 'promo_error': None,
- 'error': None
- }
-
- # 4. 应用优惠码(如果有)
- if promo_code and promo_code.strip():
- promo_code = promo_code.strip().upper()
-
- # 验证优惠码
- promo, error = validate_promo_code(
- promo_code,
- plan_code,
- billing_cycle,
- original_price,
- user_id,
- db_session
- )
-
- if promo:
- # 计算折扣
- discount = calculate_discount(promo, original_price)
- result['discount_amount'] = float(discount)
- result['final_amount'] = float(original_price - discount)
- result['promo_code'] = promo.code
- elif error:
- result['promo_error'] = error
-
- return result
-
- except Exception as e:
- return {
- 'success': False,
- 'error': f'价格计算失败: {str(e)}'
- }
-
-
-def get_current_subscription(user_id, db_session=None):
- """
- 获取用户当前生效的订阅
-
- Args:
- user_id: 用户ID
- db_session: 数据库会话(可选)
-
- Returns:
- UserSubscription 对象 或 None
- """
- from models import UserSubscription
-
- try:
- subscription = UserSubscription.query.filter_by(
- user_id=user_id,
- is_current=True
- ).first()
-
- # 检查是否过期
- if subscription and subscription.end_date < beijing_now():
- subscription.status = 'expired'
- subscription.is_current = False
- if db_session:
- db_session.commit()
- return None
-
- return subscription
-
- except Exception as e:
- print(f"获取当前订阅失败: {e}")
- return None
-
-
-def determine_subscription_type(user_id, plan_code, billing_cycle):
- """
- 判断订阅类型(新购还是续费)
-
- Args:
- user_id: 用户ID
- plan_code: 目标套餐代码
- billing_cycle: 目标计费周期
-
- Returns:
- str: 'new' 或 'renew'
- """
- current_sub = get_current_subscription(user_id)
-
- # 如果没有订阅或订阅是免费版,则为新购
- if not current_sub or current_sub.plan_code == 'free':
- return 'new'
-
- # 如果是付费订阅,则为续费
- if current_sub.plan_code in ['pro', 'max']:
- return 'renew'
-
- return 'new'
-
-
-def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None, db_session=None):
- """
- 创建订阅支付订单
-
- Args:
- user_id: 用户ID
- plan_code: 套餐代码
- billing_cycle: 计费周期
- promo_code: 优惠码(可选)
- db_session: 数据库会话
-
- Returns:
- dict: {
- 'success': True/False,
- 'order': PaymentOrder 对象,
- 'error': None
- }
- """
- from models import PaymentOrder
-
- try:
- # 1. 计算价格
- price_result = calculate_subscription_price(
- plan_code,
- billing_cycle,
- promo_code,
- user_id,
- db_session
- )
-
- if not price_result.get('success'):
- return {
- 'success': False,
- 'error': price_result.get('error', '价格计算失败')
- }
-
- # 2. 判断订阅类型
- subscription_type = determine_subscription_type(user_id, plan_code, billing_cycle)
-
- # 3. 创建支付订单
- order = PaymentOrder(
- order_no=generate_order_no(user_id),
- user_id=user_id,
- plan_code=plan_code,
- billing_cycle=billing_cycle,
- subscription_type=subscription_type,
- original_price=Decimal(str(price_result['original_price'])),
- discount_amount=Decimal(str(price_result['discount_amount'])),
- final_amount=Decimal(str(price_result['final_amount'])),
- promo_code=promo_code,
- status='pending',
- expired_at=beijing_now() + timedelta(minutes=30)
- )
-
- if db_session:
- db_session.add(order)
- db_session.commit()
-
- return {
- 'success': True,
- 'order': order,
- 'subscription_type': subscription_type,
- 'error': None
- }
-
- except Exception as e:
- if db_session:
- db_session.rollback()
- return {
- 'success': False,
- 'error': f'创建订单失败: {str(e)}'
- }
-
-
-def activate_subscription_after_payment(order_id, db_session=None):
- """
- 支付成功后激活订阅
-
- Args:
- order_id: 订单ID
- db_session: 数据库会话
-
- Returns:
- dict: {
- 'success': True/False,
- 'subscription': UserSubscription 对象,
- 'error': None
- }
- """
- from models import PaymentOrder, UserSubscription, PromoCodeUsage
-
- try:
- # 1. 查询订单
- order = PaymentOrder.query.get(order_id)
- if not order:
- return {'success': False, 'error': '订单不存在'}
-
- if order.status != 'paid':
- return {'success': False, 'error': '订单未支付'}
-
- # 2. 检查是否已经激活
- existing_sub = UserSubscription.query.filter_by(
- payment_order_id=order.id
- ).first()
-
- if existing_sub:
- return {
- 'success': True,
- 'subscription': existing_sub,
- 'message': '订阅已激活'
- }
-
- # 3. 计算订阅周期天数
- cycle_days_map = {
- 'monthly': 30,
- 'quarterly': 90,
- 'semiannual': 180,
- 'yearly': 365
- }
- days = cycle_days_map.get(order.billing_cycle, 30)
-
- # 4. 获取当前订阅
- current_sub = get_current_subscription(order.user_id, db_session)
-
- # 5. 计算开始和结束时间
- now = beijing_now()
-
- if current_sub and current_sub.end_date > now:
- # 续费:从当前订阅结束时间开始
- start_date = current_sub.end_date
- else:
- # 新购:从当前时间开始
- start_date = now
-
- end_date = start_date + timedelta(days=days)
-
- # 6. 创建新订阅记录
- new_subscription = UserSubscription(
- user_id=order.user_id,
- subscription_id=generate_subscription_id(),
- plan_code=order.plan_code,
- billing_cycle=order.billing_cycle,
- start_date=start_date,
- end_date=end_date,
- status='active',
- is_current=True,
- payment_order_id=order.id,
- paid_amount=order.final_amount,
- original_price=order.original_price,
- discount_amount=order.discount_amount,
- subscription_type=order.subscription_type,
- previous_subscription_id=current_sub.subscription_id if current_sub else None,
- auto_renew=False
- )
-
- # 7. 将旧订阅标记为非当前
- if current_sub:
- current_sub.is_current = False
-
- if db_session:
- db_session.add(new_subscription)
-
- # 8. 记录优惠码使用
- if order.promo_code_id:
- usage = PromoCodeUsage(
- promo_code_id=order.promo_code_id,
- user_id=order.user_id,
- order_id=order.id,
- discount_amount=order.discount_amount
- )
- db_session.add(usage)
-
- # 更新优惠码使用次数
- from models import PromoCode
- promo = PromoCode.query.get(order.promo_code_id)
- if promo:
- promo.current_uses += 1
-
- db_session.commit()
-
- return {
- 'success': True,
- 'subscription': new_subscription,
- 'error': None
- }
-
- except Exception as e:
- if db_session:
- db_session.rollback()
- return {
- 'success': False,
- 'error': f'激活订阅失败: {str(e)}'
- }
-
-
-def get_subscription_button_text(user_id, plan_code, billing_cycle):
- """
- 获取订阅按钮文字
-
- Args:
- user_id: 用户ID
- plan_code: 套餐代码 (pro/max)
- billing_cycle: 计费周期
-
- Returns:
- str: 按钮文字
- """
- from models import SubscriptionPlan
-
- # 获取套餐显示名称
- plan = SubscriptionPlan.query.filter_by(plan_code=plan_code).first()
- plan_name = plan.plan_name if plan else plan_code.upper()
-
- # 获取周期显示名称
- cycle_names = {
- 'monthly': '月付',
- 'quarterly': '季付',
- 'semiannual': '半年付',
- 'yearly': '年付'
- }
- cycle_name = cycle_names.get(billing_cycle, billing_cycle)
-
- # 获取当前订阅
- current_sub = get_current_subscription(user_id)
-
- # 1. 如果没有订阅或订阅已过期
- if not current_sub or current_sub.plan_code == 'free' or current_sub.status != 'active':
- return f"选择 {plan_name}"
-
- # 2. 如果是当前套餐且周期相同
- if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
- return f"续费 {plan_name}"
-
- # 3. 如果是当前套餐但周期不同
- if current_sub.plan_code == plan_code:
- return f"切换至{cycle_name}"
-
- # 4. 如果是不同套餐
- return f"选择 {plan_name}"
-
-
-# ============================================
-# 优惠码相关函数
-# ============================================
-
-def validate_promo_code(code, plan_code, billing_cycle, amount, user_id=None, db_session=None):
- """
- 验证优惠码
-
- Args:
- code: 优惠码
- plan_code: 套餐代码
- billing_cycle: 计费周期
- amount: 订单金额
- user_id: 用户ID(可选)
- db_session: 数据库会话(可选)
-
- Returns:
- tuple: (PromoCode对象 或 None, 错误信息 或 None)
- """
- from models import PromoCode, PromoCodeUsage
-
- try:
- # 查询优惠码
- promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
-
- if not promo:
- return None, "优惠码不存在或已失效"
-
- # 检查有效期
- now = beijing_now()
- if promo.valid_from and now < promo.valid_from:
- return None, "优惠码尚未生效"
-
- if promo.valid_until and now > promo.valid_until:
- return None, "优惠码已过期"
-
- # 检查总使用次数
- if promo.max_total_uses and promo.current_uses >= promo.max_total_uses:
- return None, "优惠码使用次数已达上限"
-
- # 检查每用户使用次数
- if user_id and promo.max_uses_per_user:
- user_usage_count = PromoCodeUsage.query.filter_by(
- promo_code_id=promo.id,
- user_id=user_id
- ).count()
-
- if user_usage_count >= promo.max_uses_per_user:
- return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)"
-
- # 检查适用套餐
- if promo.applicable_plans:
- try:
- applicable = json.loads(promo.applicable_plans)
- if isinstance(applicable, list) and plan_code not in applicable:
- return None, "该优惠码不适用于此套餐"
- except:
- pass
-
- # 检查适用周期
- if promo.applicable_cycles:
- try:
- applicable = json.loads(promo.applicable_cycles)
- if isinstance(applicable, list) and billing_cycle not in applicable:
- return None, "该优惠码不适用于此计费周期"
- except:
- pass
-
- # 检查最低消费
- if promo.min_amount and amount < float(promo.min_amount):
- return None, f"需满 ¥{float(promo.min_amount):.2f} 才可使用此优惠码"
-
- return promo, None
-
- except Exception as e:
- return None, f"验证优惠码时出错: {str(e)}"
-
-
-def calculate_discount(promo_code, amount):
- """
- 计算优惠金额
-
- Args:
- promo_code: PromoCode 对象
- amount: 订单金额
-
- Returns:
- Decimal: 优惠金额
- """
- try:
- if promo_code.discount_type == 'percentage':
- # 百分比折扣
- discount = Decimal(str(amount)) * Decimal(str(promo_code.discount_value)) / Decimal('100')
- elif promo_code.discount_type == 'fixed_amount':
- # 固定金额折扣
- discount = Decimal(str(promo_code.discount_value))
- else:
- discount = Decimal('0')
-
- # 确保折扣不超过总金额
- discount = min(discount, Decimal(str(amount)))
-
- return discount
-
- except Exception as e:
- print(f"计算折扣失败: {e}")
- return Decimal('0')
-
-
-# ============================================
-# 辅助查询函数
-# ============================================
-
-def get_user_subscription_history(user_id, limit=10):
- """
- 获取用户订阅历史
-
- Args:
- user_id: 用户ID
- limit: 返回记录数量限制
-
- Returns:
- list: UserSubscription 对象列表
- """
- from models import UserSubscription
-
- try:
- subscriptions = UserSubscription.query.filter_by(
- user_id=user_id
- ).order_by(
- UserSubscription.created_at.desc()
- ).limit(limit).all()
-
- return subscriptions
-
- except Exception as e:
- print(f"获取订阅历史失败: {e}")
- return []
-
-
-def check_subscription_status(user_id):
- """
- 检查用户订阅状态
-
- Args:
- user_id: 用户ID
-
- Returns:
- dict: {
- 'has_subscription': True/False,
- 'plan_code': 'pro' 或 'max' 或 'free',
- 'status': 'active' 或 'expired',
- 'end_date': datetime 或 None,
- 'days_left': int
- }
- """
- current_sub = get_current_subscription(user_id)
-
- if not current_sub or current_sub.plan_code == 'free':
- return {
- 'has_subscription': False,
- 'plan_code': 'free',
- 'status': 'active',
- 'end_date': None,
- 'days_left': 999
- }
-
- now = beijing_now()
- days_left = (current_sub.end_date - now).days if current_sub.end_date > now else 0
-
- return {
- 'has_subscription': True,
- 'plan_code': current_sub.plan_code,
- 'status': current_sub.status,
- 'end_date': current_sub.end_date,
- 'days_left': days_left
- }
diff --git a/new_subscription_routes.py b/new_subscription_routes.py
deleted file mode 100644
index f3acedf4..00000000
--- a/new_subscription_routes.py
+++ /dev/null
@@ -1,669 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-新版订阅支付系统 API 路由
-版本: v2.0.0
-日期: 2025-11-19
-
-使用方法:
-将这些路由添加到你的 Flask app.py 中
-"""
-
-from flask import jsonify, request, session
-from new_subscription_logic import (
- calculate_subscription_price,
- create_subscription_order,
- activate_subscription_after_payment,
- get_subscription_button_text,
- get_current_subscription,
- check_subscription_status,
- get_user_subscription_history
-)
-
-
-# ============================================
-# API 路由定义
-# ============================================
-
-@app.route('/api/v2/subscription/plans', methods=['GET'])
-def get_subscription_plans_v2():
- """
- 获取订阅套餐列表(新版)
-
- Response:
- {
- "success": true,
- "data": [
- {
- "plan_code": "pro",
- "plan_name": "Pro 专业版",
- "description": "为专业投资者打造",
- "prices": {
- "monthly": 299.00,
- "quarterly": 799.00,
- "semiannual": 1499.00,
- "yearly": 2699.00
- },
- "features": [...],
- "is_active": true
- },
- ...
- ]
- }
- """
- try:
- from models import SubscriptionPlan
-
- plans = SubscriptionPlan.query.filter_by(is_active=True).order_by(
- SubscriptionPlan.display_order
- ).all()
-
- data = []
- for plan in plans:
- data.append({
- 'plan_code': plan.plan_code,
- 'plan_name': plan.plan_name,
- 'description': plan.description,
- 'prices': {
- 'monthly': float(plan.price_monthly),
- 'quarterly': float(plan.price_quarterly),
- 'semiannual': float(plan.price_semiannual),
- 'yearly': float(plan.price_yearly)
- },
- 'features': json.loads(plan.features) if plan.features else [],
- 'is_active': plan.is_active,
- 'display_order': plan.display_order
- })
-
- return jsonify({
- 'success': True,
- 'data': data
- })
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'获取套餐列表失败: {str(e)}'
- }), 500
-
-
-@app.route('/api/v2/subscription/calculate-price', methods=['POST'])
-def calculate_price_v2():
- """
- 计算订阅价格(新版 - 新购和续费价格一致)
-
- Request Body:
- {
- "plan_code": "pro",
- "billing_cycle": "yearly",
- "promo_code": "WELCOME2025" // 可选
- }
-
- Response:
- {
- "success": true,
- "data": {
- "plan_code": "pro",
- "plan_name": "Pro 专业版",
- "billing_cycle": "yearly",
- "original_price": 2699.00,
- "discount_amount": 539.80,
- "final_amount": 2159.20,
- "promo_code": "WELCOME2025",
- "promo_error": null
- }
- }
- """
- try:
- if 'user_id' not in session:
- return jsonify({'success': False, 'error': '未登录'}), 401
-
- data = request.get_json()
- plan_code = data.get('plan_code')
- billing_cycle = data.get('billing_cycle')
- promo_code = data.get('promo_code')
-
- if not plan_code or not billing_cycle:
- return jsonify({
- 'success': False,
- 'error': '参数不完整'
- }), 400
-
- # 计算价格
- result = calculate_subscription_price(
- plan_code=plan_code,
- billing_cycle=billing_cycle,
- promo_code=promo_code,
- user_id=session['user_id'],
- db_session=db.session
- )
-
- if not result.get('success'):
- return jsonify(result), 400
-
- return jsonify({
- 'success': True,
- 'data': result
- })
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'计算价格失败: {str(e)}'
- }), 500
-
-
-@app.route('/api/v2/payment/create-order', methods=['POST'])
-def create_order_v2():
- """
- 创建支付订单(新版)
-
- Request Body:
- {
- "plan_code": "pro",
- "billing_cycle": "yearly",
- "promo_code": "WELCOME2025" // 可选
- }
-
- Response:
- {
- "success": true,
- "data": {
- "order_no": "1732012345678901231234",
- "plan_code": "pro",
- "billing_cycle": "yearly",
- "subscription_type": "renew", // 或 "new"
- "original_price": 2699.00,
- "discount_amount": 539.80,
- "final_amount": 2159.20,
- "qr_code_url": "https://...",
- "status": "pending",
- "expired_at": "2025-11-19T15:30:00",
- ...
- }
- }
- """
- try:
- if 'user_id' not in session:
- return jsonify({'success': False, 'error': '未登录'}), 401
-
- data = request.get_json()
- plan_code = data.get('plan_code')
- billing_cycle = data.get('billing_cycle')
- promo_code = data.get('promo_code')
-
- if not plan_code or not billing_cycle:
- return jsonify({
- 'success': False,
- 'error': '参数不完整'
- }), 400
-
- # 创建订单
- order_result = create_subscription_order(
- user_id=session['user_id'],
- plan_code=plan_code,
- billing_cycle=billing_cycle,
- promo_code=promo_code,
- db_session=db.session
- )
-
- if not order_result.get('success'):
- return jsonify({
- 'success': False,
- 'error': order_result.get('error')
- }), 400
-
- order = order_result['order']
-
- # 生成微信支付二维码
- try:
- from wechat_pay import create_wechat_pay_instance, check_wechat_pay_ready
-
- is_ready, ready_msg = check_wechat_pay_ready()
- if not is_ready:
- # 使用模拟二维码
- order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
- order.remark = f"演示模式 - {ready_msg}"
- else:
- wechat_pay = create_wechat_pay_instance()
-
- # 创建微信支付订单
- plan_display = f"{plan_code.upper()}-{billing_cycle}"
- wechat_result = wechat_pay.create_native_order(
- order_no=order.order_no,
- total_fee=float(order.final_amount),
- body=f"VFr-{plan_display}",
- product_id=f"{plan_code}_{billing_cycle}"
- )
-
- if wechat_result['success']:
- wechat_code_url = wechat_result['code_url']
-
- import urllib.parse
- encoded_url = urllib.parse.quote(wechat_code_url, safe='')
- qr_image_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encoded_url}"
-
- order.qr_code_url = qr_image_url
- order.prepay_id = wechat_result.get('prepay_id')
- order.remark = f"微信支付 - {wechat_code_url}"
- else:
- order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
- order.remark = f"微信支付失败: {wechat_result.get('error')}"
-
- except Exception as e:
- order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
- order.remark = f"支付异常: {str(e)}"
-
- db.session.commit()
-
- return jsonify({
- 'success': True,
- 'data': {
- 'id': order.id,
- 'order_no': order.order_no,
- 'plan_code': order.plan_code,
- 'billing_cycle': order.billing_cycle,
- 'subscription_type': order.subscription_type,
- 'original_price': float(order.original_price),
- 'discount_amount': float(order.discount_amount),
- 'final_amount': float(order.final_amount),
- 'promo_code': order.promo_code,
- 'qr_code_url': order.qr_code_url,
- 'status': order.status,
- 'expired_at': order.expired_at.isoformat() if order.expired_at else None,
- 'created_at': order.created_at.isoformat() if order.created_at else None
- },
- 'message': '订单创建成功'
- })
-
- except Exception as e:
- db.session.rollback()
- return jsonify({
- 'success': False,
- 'error': f'创建订单失败: {str(e)}'
- }), 500
-
-
-@app.route('/api/v2/payment/order//status', methods=['GET'])
-def check_order_status_v2(order_id):
- """
- 查询订单支付状态(新版)
-
- Response:
- {
- "success": true,
- "payment_success": true, // 是否支付成功
- "data": {
- "order_no": "...",
- "status": "paid",
- ...
- },
- "message": "支付成功!订阅已激活"
- }
- """
- try:
- if 'user_id' not in session:
- return jsonify({'success': False, 'error': '未登录'}), 401
-
- from models import PaymentOrder
-
- order = PaymentOrder.query.filter_by(
- id=order_id,
- user_id=session['user_id']
- ).first()
-
- if not order:
- return jsonify({'success': False, 'error': '订单不存在'}), 404
-
- # 如果订单已经是已支付状态
- if order.status == 'paid':
- return jsonify({
- 'success': True,
- 'payment_success': True,
- 'data': {
- 'order_no': order.order_no,
- 'status': order.status,
- 'final_amount': float(order.final_amount)
- },
- 'message': '订单已支付'
- })
-
- # 如果订单过期
- if order.is_expired():
- order.status = 'expired'
- db.session.commit()
- return jsonify({
- 'success': True,
- 'payment_success': False,
- 'data': {'status': 'expired'},
- 'message': '订单已过期'
- })
-
- # 调用微信支付API查询状态
- try:
- from wechat_pay import create_wechat_pay_instance
- wechat_pay = create_wechat_pay_instance()
-
- query_result = wechat_pay.query_order(order_no=order.order_no)
-
- if query_result['success']:
- trade_state = query_result.get('trade_state')
- transaction_id = query_result.get('transaction_id')
-
- if trade_state == 'SUCCESS':
- # 支付成功
- order.mark_as_paid(transaction_id)
- db.session.commit()
-
- # 激活订阅
- activate_result = activate_subscription_after_payment(
- order.id,
- db_session=db.session
- )
-
- if activate_result.get('success'):
- return jsonify({
- 'success': True,
- 'payment_success': True,
- 'data': {
- 'order_no': order.order_no,
- 'status': 'paid'
- },
- 'message': '支付成功!订阅已激活'
- })
- else:
- return jsonify({
- 'success': True,
- 'payment_success': True,
- 'data': {'status': 'paid'},
- 'message': '支付成功,但激活失败,请联系客服'
- })
-
- elif trade_state in ['NOTPAY', 'USERPAYING']:
- return jsonify({
- 'success': True,
- 'payment_success': False,
- 'data': {'status': 'pending'},
- 'message': '等待支付...'
- })
- else:
- order.status = 'cancelled'
- db.session.commit()
- return jsonify({
- 'success': True,
- 'payment_success': False,
- 'data': {'status': 'cancelled'},
- 'message': '支付已取消'
- })
-
- except Exception as e:
- # 查询失败,返回当前状态
- pass
-
- return jsonify({
- 'success': True,
- 'payment_success': False,
- 'data': {'status': order.status},
- 'message': '无法查询支付状态,请稍后重试'
- })
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'查询失败: {str(e)}'
- }), 500
-
-
-@app.route('/api/v2/payment/order//force-update', methods=['POST'])
-def force_update_status_v2(order_id):
- """
- 强制更新订单支付状态(新版)
-
- 用于支付完成但页面未更新的情况
- """
- try:
- if 'user_id' not in session:
- return jsonify({'success': False, 'error': '未登录'}), 401
-
- from models import PaymentOrder
-
- order = PaymentOrder.query.filter_by(
- id=order_id,
- user_id=session['user_id']
- ).first()
-
- if not order:
- return jsonify({'success': False, 'error': '订单不存在'}), 404
-
- # 检查微信支付状态
- try:
- from wechat_pay import create_wechat_pay_instance
- wechat_pay = create_wechat_pay_instance()
-
- query_result = wechat_pay.query_order(order_no=order.order_no)
-
- if query_result['success'] and query_result.get('trade_state') == 'SUCCESS':
- transaction_id = query_result.get('transaction_id')
-
- # 标记订单为已支付
- order.mark_as_paid(transaction_id)
- db.session.commit()
-
- # 激活订阅
- activate_result = activate_subscription_after_payment(
- order.id,
- db_session=db.session
- )
-
- if activate_result.get('success'):
- return jsonify({
- 'success': True,
- 'payment_success': True,
- 'message': '状态更新成功!订阅已激活'
- })
- else:
- return jsonify({
- 'success': True,
- 'payment_success': True,
- 'message': '支付成功,但激活失败,请联系客服',
- 'error': activate_result.get('error')
- })
- else:
- return jsonify({
- 'success': True,
- 'payment_success': False,
- 'message': '微信支付状态未更新,请继续等待'
- })
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'查询微信支付状态失败: {str(e)}'
- }), 500
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'强制更新失败: {str(e)}'
- }), 500
-
-
-@app.route('/api/v2/subscription/current', methods=['GET'])
-def get_current_subscription_v2():
- """
- 获取当前用户订阅信息(新版)
-
- Response:
- {
- "success": true,
- "data": {
- "subscription_id": "SUB_1732012345_12345",
- "plan_code": "pro",
- "plan_name": "Pro 专业版",
- "billing_cycle": "yearly",
- "status": "active",
- "start_date": "2025-11-19T00:00:00",
- "end_date": "2026-11-19T00:00:00",
- "days_left": 365,
- "auto_renew": false
- }
- }
- """
- try:
- if 'user_id' not in session:
- return jsonify({'success': False, 'error': '未登录'}), 401
-
- from models import SubscriptionPlan
-
- subscription = get_current_subscription(session['user_id'])
-
- if not subscription:
- return jsonify({
- 'success': True,
- 'data': {
- 'plan_code': 'free',
- 'plan_name': '免费版',
- 'status': 'active'
- }
- })
-
- # 获取套餐名称
- plan = SubscriptionPlan.query.filter_by(plan_code=subscription.plan_code).first()
- plan_name = plan.plan_name if plan else subscription.plan_code.upper()
-
- # 计算剩余天数
- from datetime import datetime
- now = datetime.now()
- days_left = (subscription.end_date - now).days if subscription.end_date > now else 0
-
- return jsonify({
- 'success': True,
- 'data': {
- 'subscription_id': subscription.subscription_id,
- 'plan_code': subscription.plan_code,
- 'plan_name': plan_name,
- 'billing_cycle': subscription.billing_cycle,
- 'status': subscription.status,
- 'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
- 'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
- 'days_left': days_left,
- 'auto_renew': subscription.auto_renew
- }
- })
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'获取订阅信息失败: {str(e)}'
- }), 500
-
-
-@app.route('/api/v2/subscription/history', methods=['GET'])
-def get_subscription_history_v2():
- """
- 获取用户订阅历史(新版)
-
- Query Params:
- limit: 返回记录数量(默认10)
-
- Response:
- {
- "success": true,
- "data": [
- {
- "subscription_id": "SUB_...",
- "plan_code": "pro",
- "billing_cycle": "yearly",
- "start_date": "...",
- "end_date": "...",
- "paid_amount": 2699.00,
- "status": "expired"
- },
- ...
- ]
- }
- """
- try:
- if 'user_id' not in session:
- return jsonify({'success': False, 'error': '未登录'}), 401
-
- limit = request.args.get('limit', 10, type=int)
-
- subscriptions = get_user_subscription_history(session['user_id'], limit)
-
- data = []
- for sub in subscriptions:
- data.append({
- 'subscription_id': sub.subscription_id,
- 'plan_code': sub.plan_code,
- 'billing_cycle': sub.billing_cycle,
- 'start_date': sub.start_date.isoformat() if sub.start_date else None,
- 'end_date': sub.end_date.isoformat() if sub.end_date else None,
- 'paid_amount': float(sub.paid_amount),
- 'original_price': float(sub.original_price),
- 'discount_amount': float(sub.discount_amount),
- 'status': sub.status,
- 'created_at': sub.created_at.isoformat() if sub.created_at else None
- })
-
- return jsonify({
- 'success': True,
- 'data': data
- })
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'获取订阅历史失败: {str(e)}'
- }), 500
-
-
-@app.route('/api/v2/subscription/button-text', methods=['POST'])
-def get_button_text_v2():
- """
- 获取订阅按钮文字(新版)
-
- Request Body:
- {
- "plan_code": "pro",
- "billing_cycle": "yearly"
- }
-
- Response:
- {
- "success": true,
- "button_text": "续费 Pro 专业版"
- }
- """
- try:
- if 'user_id' not in session:
- return jsonify({
- 'success': True,
- 'button_text': '选择套餐'
- })
-
- data = request.get_json()
- plan_code = data.get('plan_code')
- billing_cycle = data.get('billing_cycle')
-
- if not plan_code or not billing_cycle:
- return jsonify({
- 'success': False,
- 'error': '参数不完整'
- }), 400
-
- button_text = get_subscription_button_text(
- session['user_id'],
- plan_code,
- billing_cycle
- )
-
- return jsonify({
- 'success': True,
- 'button_text': button_text
- })
-
- except Exception as e:
- return jsonify({
- 'success': False,
- 'error': f'获取按钮文字失败: {str(e)}'
- }), 500
diff --git a/src/routes/homeRoutes.js b/src/routes/homeRoutes.js
index 5ad2ed5c..9e2d7d00 100644
--- a/src/routes/homeRoutes.js
+++ b/src/routes/homeRoutes.js
@@ -102,6 +102,17 @@ export const homeRoutes = [
}
},
+ // 数据浏览器 - /home/data-browser
+ {
+ path: 'data-browser',
+ component: lazyComponents.DataBrowser,
+ protection: PROTECTION_MODES.MODAL,
+ meta: {
+ title: '数据浏览器',
+ description: '化工商品数据分类树浏览器'
+ }
+ },
+
// 回退路由 - 匹配任何未定义的 /home/* 路径
{
path: '*',
diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js
index 0f48bbed..48353be6 100644
--- a/src/routes/lazy-components.js
+++ b/src/routes/lazy-components.js
@@ -42,6 +42,9 @@ export const lazyComponents = {
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
+
+ // 数据浏览器模块
+ DataBrowser: React.lazy(() => import('../views/DataBrowser')),
};
/**
@@ -69,4 +72,5 @@ export const {
AgentChat,
ValueForum,
ForumPostDetail,
+ DataBrowser,
} = lazyComponents;
diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts
new file mode 100644
index 00000000..3548d1f8
--- /dev/null
+++ b/src/services/categoryService.ts
@@ -0,0 +1,192 @@
+/**
+ * 商品分类树数据服务
+ * 对接化工商品数据分类树API
+ * API文档: category_tree_openapi.json
+ */
+
+import { getApiBase } from '@utils/apiConfig';
+
+// 类型定义
+export interface TreeMetric {
+ metric_id: string;
+ metric_name: string;
+ source: 'SMM' | 'Mysteel';
+ frequency: string;
+ unit: string;
+ description?: string;
+}
+
+export interface TreeNode {
+ name: string;
+ path: string;
+ level: number;
+ children?: TreeNode[];
+ metrics?: TreeMetric[];
+}
+
+export interface CategoryTreeResponse {
+ source: 'SMM' | 'Mysteel';
+ total_metrics: number;
+ tree: TreeNode[];
+}
+
+export interface ErrorResponse {
+ detail: string;
+}
+
+/**
+ * 获取完整分类树
+ * @param source 数据源类型 ('SMM' | 'Mysteel')
+ * @returns 完整的分类树数据
+ */
+export const fetchCategoryTree = async (
+ source: 'SMM' | 'Mysteel'
+): Promise => {
+ try {
+ const response = await fetch(`/category-api/api/category-tree?source=${source}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const errorData: ErrorResponse = await response.json();
+ throw new Error(errorData.detail || `HTTP ${response.status}`);
+ }
+
+ const data: CategoryTreeResponse = await response.json();
+ return data;
+ } catch (error) {
+ console.error('fetchCategoryTree error:', error);
+ throw error;
+ }
+};
+
+/**
+ * 获取特定节点及其子树
+ * @param path 节点完整路径(用 | 分隔)
+ * @param source 数据源类型 ('SMM' | 'Mysteel')
+ * @returns 节点数据及其子树
+ */
+export const fetchCategoryNode = async (
+ path: string,
+ source: 'SMM' | 'Mysteel'
+): Promise => {
+ try {
+ const encodedPath = encodeURIComponent(path);
+ const response = await fetch(
+ `/category-api/api/category-tree/node?path=${encodedPath}&source=${source}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData: ErrorResponse = await response.json();
+ throw new Error(errorData.detail || `HTTP ${response.status}`);
+ }
+
+ const data: TreeNode = await response.json();
+ return data;
+ } catch (error) {
+ console.error('fetchCategoryNode error:', error);
+ throw error;
+ }
+};
+
+/**
+ * 搜索指标
+ * @param query 搜索关键词
+ * @param source 数据源类型 ('SMM' | 'Mysteel')
+ * @returns 匹配的指标列表
+ */
+export const searchMetrics = async (
+ query: string,
+ source: 'SMM' | 'Mysteel'
+): Promise => {
+ try {
+ // 注意:这个接口可能需要后端额外实现
+ // 如果后端没有提供搜索接口,可以在前端基于完整树进行过滤
+ const response = await fetch(
+ `/category-api/api/metrics/search?query=${encodeURIComponent(query)}&source=${source}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data: TreeMetric[] = await response.json();
+ return data;
+ } catch (error) {
+ console.error('searchMetrics error:', error);
+ throw error;
+ }
+};
+
+/**
+ * 从树中提取所有指标(用于前端搜索)
+ * @param nodes 树节点数组
+ * @returns 所有指标的扁平化数组
+ */
+export const extractAllMetrics = (nodes: TreeNode[]): TreeMetric[] => {
+ const metrics: TreeMetric[] = [];
+
+ const traverse = (node: TreeNode) => {
+ if (node.metrics && node.metrics.length > 0) {
+ metrics.push(...node.metrics);
+ }
+ if (node.children && node.children.length > 0) {
+ node.children.forEach(traverse);
+ }
+ };
+
+ nodes.forEach(traverse);
+ return metrics;
+};
+
+/**
+ * 在树中查找节点
+ * @param nodes 树节点数组
+ * @param path 节点路径
+ * @returns 找到的节点或 null
+ */
+export const findNodeByPath = (nodes: TreeNode[], path: string): TreeNode | null => {
+ for (const node of nodes) {
+ if (node.path === path) {
+ return node;
+ }
+ if (node.children) {
+ const found = findNodeByPath(node.children, path);
+ if (found) {
+ return found;
+ }
+ }
+ }
+ return null;
+};
+
+/**
+ * 获取节点的所有父节点路径
+ * @param path 节点路径(用 | 分隔)
+ * @returns 父节点路径数组
+ */
+export const getParentPaths = (path: string): string[] => {
+ const parts = path.split('|');
+ const parentPaths: string[] = [];
+
+ for (let i = 1; i < parts.length; i++) {
+ parentPaths.push(parts.slice(0, i).join('|'));
+ }
+
+ return parentPaths;
+};
diff --git a/src/views/DataBrowser/index.tsx b/src/views/DataBrowser/index.tsx
new file mode 100644
index 00000000..6e0910cf
--- /dev/null
+++ b/src/views/DataBrowser/index.tsx
@@ -0,0 +1,670 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import {
+ Box,
+ Container,
+ Flex,
+ Text,
+ Input,
+ Button,
+ VStack,
+ HStack,
+ Badge,
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ Icon,
+ Spinner,
+ useToast,
+ Card,
+ CardBody,
+ Divider,
+ SimpleGrid,
+ Collapse,
+} from '@chakra-ui/react';
+import {
+ FaDatabase,
+ FaFolder,
+ FaFolderOpen,
+ FaFile,
+ FaSearch,
+ FaHome,
+ FaChevronRight,
+ FaChevronDown,
+ FaChevronUp,
+ FaFilter,
+ FaTimes,
+} from 'react-icons/fa';
+import { motion } from 'framer-motion';
+import { fetchCategoryTree, fetchCategoryNode } from '@services/categoryService';
+
+// 黑金主题配色
+const themeColors = {
+ bgGradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)',
+ bgRadialGold: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
+ primary: {
+ gold: '#D4AF37',
+ goldLight: '#F4E3A7',
+ goldDark: '#B8941F',
+ },
+ bg: {
+ primary: '#0a0a0a',
+ secondary: '#1a1a1a',
+ card: '#1e1e1e',
+ cardHover: '#252525',
+ },
+ text: {
+ primary: '#ffffff',
+ secondary: '#b8b8b8',
+ muted: '#808080',
+ gold: '#D4AF37',
+ },
+ border: {
+ default: 'rgba(255, 255, 255, 0.1)',
+ gold: 'rgba(212, 175, 55, 0.3)',
+ goldGlow: 'rgba(212, 175, 55, 0.5)',
+ },
+};
+
+const MotionBox = motion(Box);
+const MotionCard = motion(Card);
+
+interface TreeNode {
+ name: string;
+ path: string;
+ level: number;
+ children?: TreeNode[];
+ metrics?: TreeMetric[];
+}
+
+interface TreeMetric {
+ metric_id: string;
+ metric_name: string;
+ source: string;
+ frequency: string;
+ unit: string;
+ description?: string;
+}
+
+interface CategoryTreeResponse {
+ source: string;
+ total_metrics: number;
+ tree: TreeNode[];
+}
+
+// 树节点组件
+const TreeNodeComponent: React.FC<{
+ node: TreeNode;
+ onNodeClick: (node: TreeNode) => void;
+ expandedNodes: Set;
+ onToggleExpand: (path: string) => void;
+ searchQuery: string;
+}> = ({ node, onNodeClick, expandedNodes, onToggleExpand, searchQuery }) => {
+ const isExpanded = expandedNodes.has(node.path);
+ const hasChildren = node.children && node.children.length > 0;
+ const hasMetrics = node.metrics && node.metrics.length > 0;
+
+ // 高亮搜索关键词
+ const highlightText = (text: string) => {
+ if (!searchQuery) return text;
+ const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
+ return parts.map((part, index) =>
+ part.toLowerCase() === searchQuery.toLowerCase() ? (
+
+ {part}
+
+ ) : (
+ part
+ )
+ );
+ };
+
+ return (
+
+ {
+ if (hasChildren) {
+ onToggleExpand(node.path);
+ }
+ onNodeClick(node);
+ }}
+ >
+ {hasChildren ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {highlightText(node.name)}
+
+
+ {hasMetrics && (
+
+ {node.metrics.length}
+
+ )}
+
+
+ {isExpanded && hasChildren && (
+
+ {node.children!.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+// 指标卡片组件
+const MetricCard: React.FC<{ metric: TreeMetric }> = ({ metric }) => {
+ return (
+
+
+
+
+
+ {metric.metric_name}
+
+
+ {metric.source}
+
+
+
+
+
+
+
+
+ 频率
+
+
+ {metric.frequency}
+
+
+
+
+ 单位
+
+
+ {metric.unit || '-'}
+
+
+
+
+ {metric.description && (
+
+ {metric.description}
+
+ )}
+
+
+ ID: {metric.metric_id}
+
+
+
+
+ );
+};
+
+const DataBrowser: React.FC = () => {
+ const [selectedSource, setSelectedSource] = useState<'SMM' | 'Mysteel'>('SMM');
+ const [treeData, setTreeData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [currentNode, setCurrentNode] = useState(null);
+ const [breadcrumbs, setBreadcrumbs] = useState([]);
+ const [expandedNodes, setExpandedNodes] = useState>(new Set());
+ const [showFilters, setShowFilters] = useState(false);
+
+ const toast = useToast();
+
+ // 加载分类树
+ useEffect(() => {
+ loadCategoryTree();
+ }, [selectedSource]);
+
+ const loadCategoryTree = async () => {
+ setLoading(true);
+ try {
+ const data = await fetchCategoryTree(selectedSource);
+ setTreeData(data);
+ setCurrentNode(null);
+ setBreadcrumbs([]);
+ } catch (error) {
+ toast({
+ title: '加载失败',
+ description: '无法加载分类树数据',
+ status: 'error',
+ duration: 3000,
+ isClosable: true,
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 切换节点展开状态
+ const toggleNodeExpand = (path: string) => {
+ setExpandedNodes((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(path)) {
+ newSet.delete(path);
+ } else {
+ newSet.add(path);
+ }
+ return newSet;
+ });
+ };
+
+ // 处理节点点击
+ const handleNodeClick = (node: TreeNode) => {
+ setCurrentNode(node);
+ const pathParts = node.path.split('|');
+ setBreadcrumbs(pathParts);
+ };
+
+ // 处理面包屑导航
+ const handleBreadcrumbClick = (index: number) => {
+ if (index === -1) {
+ setCurrentNode(null);
+ setBreadcrumbs([]);
+ return;
+ }
+
+ const targetPath = breadcrumbs.slice(0, index + 1).join('|');
+ // 在树中查找对应节点
+ const findNode = (nodes: TreeNode[], path: string): TreeNode | null => {
+ for (const node of nodes) {
+ if (node.path === path) return node;
+ if (node.children) {
+ const found = findNode(node.children, path);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+
+ if (treeData) {
+ const node = findNode(treeData.tree, targetPath);
+ if (node) {
+ handleNodeClick(node);
+ }
+ }
+ };
+
+ // 过滤树节点(根据搜索关键词)
+ const filteredTree = useMemo(() => {
+ if (!treeData || !searchQuery) return treeData?.tree || [];
+
+ const filterNodes = (nodes: TreeNode[]): TreeNode[] => {
+ return nodes
+ .map((node) => {
+ const matchesName = node.name.toLowerCase().includes(searchQuery.toLowerCase());
+ const filteredChildren = node.children ? filterNodes(node.children) : [];
+
+ if (matchesName || filteredChildren.length > 0) {
+ return {
+ ...node,
+ children: filteredChildren,
+ };
+ }
+ return null;
+ })
+ .filter(Boolean) as TreeNode[];
+ };
+
+ return filterNodes(treeData.tree);
+ }, [treeData, searchQuery]);
+
+ return (
+
+ {/* 金色光晕背景 */}
+
+
+
+ {/* 标题区域 */}
+
+
+
+
+
+
+ 数据浏览器
+
+
+ 化工商品数据分类树 - 探索海量行业指标
+
+
+
+
+ {/* 数据源切换 */}
+
+
+
+
+
+
+
+ {/* 搜索和过滤 */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ bg={themeColors.bg.secondary}
+ borderColor={themeColors.border.default}
+ color={themeColors.text.primary}
+ _placeholder={{ color: themeColors.text.muted }}
+ _focus={{
+ borderColor: themeColors.primary.gold,
+ boxShadow: `0 0 0 1px ${themeColors.primary.gold}`,
+ }}
+ />
+ }
+ bg={themeColors.primary.gold}
+ color={themeColors.bg.primary}
+ _hover={{ bg: themeColors.primary.goldLight }}
+ >
+ 搜索
+
+ {searchQuery && (
+ }
+ variant="ghost"
+ color={themeColors.text.secondary}
+ _hover={{ color: themeColors.text.primary }}
+ onClick={() => setSearchQuery('')}
+ >
+ 清除
+
+ )}
+
+
+
+
+
+ {/* 面包屑导航 */}
+ {breadcrumbs.length > 0 && (
+
+
+
+ }
+ >
+
+ handleBreadcrumbClick(-1)}
+ >
+
+
+
+ {breadcrumbs.map((crumb, index) => (
+
+ handleBreadcrumbClick(index)}
+ >
+ {crumb}
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* 主内容区域 */}
+
+ {/* 左侧:分类树 */}
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {filteredTree.map((node) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {/* 右侧:指标详情 */}
+
+
+
+ {currentNode ? (
+
+
+
+
+ {currentNode.name}
+
+
+ 层级 {currentNode.level} | 路径: {currentNode.path}
+
+
+ {currentNode.metrics && currentNode.metrics.length > 0 && (
+
+ {currentNode.metrics.length} 个指标
+
+ )}
+
+
+
+
+ {currentNode.metrics && currentNode.metrics.length > 0 ? (
+
+ {currentNode.metrics.map((metric) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+ {currentNode.children && currentNode.children.length > 0
+ ? '该节点包含子分类,请展开查看'
+ : '该节点暂无指标数据'}
+
+
+
+ )}
+
+ ) : (
+
+
+
+
+ 选择左侧分类树节点查看详情
+
+ {treeData && (
+
+ 当前数据源共有 {treeData.total_metrics.toLocaleString()} 个指标
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default DataBrowser;
diff --git a/update_pricing_options.sql b/update_pricing_options.sql
deleted file mode 100644
index 04e1cfdb..00000000
--- a/update_pricing_options.sql
+++ /dev/null
@@ -1,100 +0,0 @@
--- ============================================
--- 更新订阅套餐价格配置
--- 用途:为 subscription_plans 表添加季付、半年付价格
--- 日期:2025-11-19
--- ============================================
-
--- 更新 Pro 专业版的 pricing_options
-UPDATE subscription_plans
-SET pricing_options = JSON_ARRAY(
- JSON_OBJECT(
- 'months', 1,
- 'price', 299.00,
- 'label', '月付',
- 'cycle_key', 'monthly',
- 'discount_percent', 0
- ),
- JSON_OBJECT(
- 'months', 3,
- 'price', 799.00,
- 'label', '季付',
- 'cycle_key', 'quarterly',
- 'discount_percent', 11,
- 'original_price', 897.00
- ),
- JSON_OBJECT(
- 'months', 6,
- 'price', 1499.00,
- 'label', '半年付',
- 'cycle_key', 'semiannual',
- 'discount_percent', 16,
- 'original_price', 1794.00
- ),
- JSON_OBJECT(
- 'months', 12,
- 'price', 2699.00,
- 'label', '年付',
- 'cycle_key', 'yearly',
- 'discount_percent', 25,
- 'original_price', 3588.00
- )
-)
-WHERE name = 'pro';
-
--- 更新 Max 旗舰版的 pricing_options
-UPDATE subscription_plans
-SET pricing_options = JSON_ARRAY(
- JSON_OBJECT(
- 'months', 1,
- 'price', 599.00,
- 'label', '月付',
- 'cycle_key', 'monthly',
- 'discount_percent', 0
- ),
- JSON_OBJECT(
- 'months', 3,
- 'price', 1599.00,
- 'label', '季付',
- 'cycle_key', 'quarterly',
- 'discount_percent', 11,
- 'original_price', 1797.00
- ),
- JSON_OBJECT(
- 'months', 6,
- 'price', 2999.00,
- 'label', '半年付',
- 'cycle_key', 'semiannual',
- 'discount_percent', 17,
- 'original_price', 3594.00
- ),
- JSON_OBJECT(
- 'months', 12,
- 'price', 5399.00,
- 'label', '年付',
- 'cycle_key', 'yearly',
- 'discount_percent', 25,
- 'original_price', 7188.00
- )
-)
-WHERE name = 'max';
-
--- 验证更新结果
-SELECT
- name AS '套餐',
- display_name AS '显示名称',
- pricing_options AS '价格配置'
-FROM subscription_plans
-WHERE name IN ('pro', 'max');
-
--- 完成提示
-SELECT '价格配置已更新!' AS '状态';
-SELECT '新价格:' AS '';
-SELECT ' Pro 月付: ¥299' AS '';
-SELECT ' Pro 季付: ¥799 (省11%)' AS '';
-SELECT ' Pro 半年付: ¥1499 (省16%)' AS '';
-SELECT ' Pro 年付: ¥2699 (省25%)' AS '';
-SELECT '' AS '';
-SELECT ' Max 月付: ¥599' AS '';
-SELECT ' Max 季付: ¥1599 (省11%)' AS '';
-SELECT ' Max 半年付: ¥2999 (省17%)' AS '';
-SELECT ' Max 年付: ¥5399 (省25%)' AS '';
diff --git a/valuefrontier b/valuefrontier.conf
similarity index 90%
rename from valuefrontier
rename to valuefrontier.conf
index be0d2bb8..4a6b5efc 100644
--- a/valuefrontier
+++ b/valuefrontier.conf
@@ -35,6 +35,28 @@ server {
ssl_certificate /etc/letsencrypt/live/valuefrontier.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/valuefrontier.cn/privkey.pem;
+
+ # ============================================
+ # SEO 文件配置(robots.txt + sitemap.xml)
+ # 优先级最高,放在最前面
+ # ============================================
+
+ # robots.txt - 精确匹配(优先级最高)
+ location = /robots.txt {
+ root /var/www/valuefrontier;
+ add_header Content-Type "text/plain; charset=utf-8";
+ add_header Cache-Control "public, max-age=3600"; # 缓存 1 小时
+ access_log off; # 减少日志记录
+ }
+
+ # sitemap.xml - 精确匹配
+ location = /sitemap.xml {
+ root /var/www/valuefrontier;
+ add_header Content-Type "application/xml; charset=utf-8";
+ add_header Cache-Control "public, max-age=3600"; # 缓存 1 小时
+ access_log off;
+ }
+
# --- 为React应用提供静态资源 ---
location /static/ {
alias /var/www/valuefrontier.cn/static/;
@@ -404,6 +426,37 @@ server {
proxy_read_timeout 120s;
}
+ # 商品分类树数据API代理(数据浏览器)
+ location /category-api/ {
+ proxy_pass http://222.128.1.157:18827/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # CORS 配置
+ add_header 'Access-Control-Allow-Origin' '*' always;
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
+
+ # 处理 OPTIONS 预检请求
+ if ($request_method = 'OPTIONS') {
+ return 204;
+ }
+
+ # 超时设置(数据量大,需要较长超时时间)
+ proxy_connect_timeout 120s;
+ proxy_send_timeout 120s;
+ proxy_read_timeout 120s;
+
+ # 缓冲配置(支持大响应体)
+ proxy_buffering on;
+ proxy_buffer_size 128k;
+ proxy_buffers 8 256k;
+ proxy_busy_buffers_size 512k;
+ }
+
# --- 新的静态官网静态资源(优先级最高) ---
# 使用 ^~ 前缀确保优先匹配,不被后面的规则覆盖
location ^~ /css/ {