From 80676dd622d0fb6149ebcf0712110e3e68a02958 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 20 Nov 2025 12:55:28 +0800 Subject: [PATCH] update pay function --- category_tree_openapi.json | 465 +++++++++++++++++++ database_migration.sql | 427 ------------------ index.pug | 339 -------------- new_subscription_logic.py | 631 -------------------------- new_subscription_routes.py | 669 --------------------------- src/routes/homeRoutes.js | 11 + src/routes/lazy-components.js | 4 + src/services/categoryService.ts | 192 ++++++++ src/views/DataBrowser/index.tsx | 670 ++++++++++++++++++++++++++++ update_pricing_options.sql | 100 ----- valuefrontier => valuefrontier.conf | 53 +++ 11 files changed, 1395 insertions(+), 2166 deletions(-) create mode 100644 category_tree_openapi.json delete mode 100644 database_migration.sql delete mode 100644 index.pug delete mode 100644 new_subscription_logic.py delete mode 100644 new_subscription_routes.py create mode 100644 src/services/categoryService.ts create mode 100644 src/views/DataBrowser/index.tsx delete mode 100644 update_pricing_options.sql rename valuefrontier => valuefrontier.conf (90%) 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 -
-
-
-
- - - - 金融AI技术领航者 -
-
智能舆情分析系统
-
-
- - - - 深度数据挖掘 -
-
- - - - 7×24小时监控 -
-
-
基于金融领域微调的大语言模型,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 -
-
-
-
订阅方案
-
立即开启智能决策
-
-
-
-
PRO
-
-
-
-
¥198
-
/月
-
- 选择Pro版 -
-
-
-
- - - -
-
事件关联股票深度分析
-
-
-
- - - -
-
历史事件智能对比复盘
-
-
-
- - - -
-
事件概念关联与挖掘
-
-
-
- - - -
-
概念板块个股追踪
-
-
-
- - - -
-
概念深度研报与解读
-
-
-
- - - -
-
个股异动实时预警
-
-
-
-
-
-
- video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline) -
-
MAX
-
-
-
-
¥998
-
/月
-
- 选择Max版 -
-
-
-
- - - -
-
包含Pro版全部功能
-
-
-
- - - -
-
事件传导链路智能分析
-
-
-
- - - -
-
概念演变时间轴追溯
-
-
-
- - - -
-
个股全方位深度研究
-
-
-
- - - -
-
价小前投研助手无限使用
-
-
-
- - - -
-
新功能优先体验权
-
-
-
- - - -
-
专属客服一对一服务
-
-
-
-
-
-
-
- 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}`, + }} + /> + + {searchQuery && ( + + )} + + + + + + {/* 面包屑导航 */} + {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/ {