From 1361a2b5b2f5d43399fea987d01c1efe69a654b0 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Wed, 5 Nov 2025 14:39:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E4=BC=98=E6=83=A0=E7=A0=81?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=EF=BC=8C=E9=A2=84=E7=BD=AE3=E4=B8=AA?= =?UTF-8?q?=E4=BC=98=E6=83=A0=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 480 +++++++++++++++++- migrations/add_promo_code_tables.sql | 134 +++++ .../Subscription/SubscriptionContent.js | 240 ++++++++- 3 files changed, 823 insertions(+), 31 deletions(-) create mode 100644 migrations/add_promo_code_tables.sql diff --git a/app.py b/app.py index 186bdb6d..7cf4d8ce 100755 --- a/app.py +++ b/app.py @@ -776,6 +776,10 @@ class PaymentOrder(db.Model): 'plan_name': self.plan_name, 'billing_cycle': self.billing_cycle, 'amount': float(self.amount) if self.amount else 0, + 'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None, + 'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0, + 'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None, + 'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False, 'qr_code_url': self.qr_code_url, 'status': self.status, 'is_expired': self.is_expired(), @@ -786,6 +790,107 @@ class PaymentOrder(db.Model): } +class PromoCode(db.Model): + """优惠码表""" + __tablename__ = 'promo_codes' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + code = db.Column(db.String(50), unique=True, nullable=False, index=True) + description = db.Column(db.String(200), nullable=True) + + # 折扣类型和值 + discount_type = db.Column(db.String(20), nullable=False) # 'percentage' 或 'fixed_amount' + discount_value = db.Column(db.Numeric(10, 2), nullable=False) + + # 适用范围 + applicable_plans = db.Column(db.String(200), nullable=True) # JSON格式 + applicable_cycles = db.Column(db.String(50), nullable=True) # JSON格式 + min_amount = db.Column(db.Numeric(10, 2), nullable=True) + + # 使用限制 + max_uses = db.Column(db.Integer, nullable=True) + max_uses_per_user = db.Column(db.Integer, default=1) + current_uses = db.Column(db.Integer, default=0) + + # 有效期 + valid_from = db.Column(db.DateTime, nullable=False) + valid_until = db.Column(db.DateTime, nullable=False) + + # 状态 + is_active = db.Column(db.Boolean, default=True) + created_by = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + def to_dict(self): + return { + 'id': self.id, + 'code': self.code, + 'description': self.description, + 'discount_type': self.discount_type, + 'discount_value': float(self.discount_value) if self.discount_value else 0, + 'applicable_plans': json.loads(self.applicable_plans) if self.applicable_plans else None, + 'applicable_cycles': json.loads(self.applicable_cycles) if self.applicable_cycles else None, + 'min_amount': float(self.min_amount) if self.min_amount else None, + 'max_uses': self.max_uses, + 'max_uses_per_user': self.max_uses_per_user, + 'current_uses': self.current_uses, + 'valid_from': self.valid_from.isoformat() if self.valid_from else None, + 'valid_until': self.valid_until.isoformat() if self.valid_until else None, + 'is_active': self.is_active + } + + +class PromoCodeUsage(db.Model): + """优惠码使用记录表""" + __tablename__ = 'promo_code_usage' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False) + user_id = db.Column(db.Integer, nullable=False, index=True) + order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False) + + original_amount = db.Column(db.Numeric(10, 2), nullable=False) + discount_amount = db.Column(db.Numeric(10, 2), nullable=False) + final_amount = db.Column(db.Numeric(10, 2), nullable=False) + + used_at = db.Column(db.DateTime, default=beijing_now) + + # 关系 + promo_code = db.relationship('PromoCode', backref='usages') + order = db.relationship('PaymentOrder', backref='promo_usage') + + +class SubscriptionUpgrade(db.Model): + """订阅升级/降级记录表""" + __tablename__ = 'subscription_upgrades' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, nullable=False, index=True) + order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False) + + # 原订阅信息 + from_plan = db.Column(db.String(20), nullable=False) + from_cycle = db.Column(db.String(10), nullable=False) + from_end_date = db.Column(db.DateTime, nullable=True) + + # 新订阅信息 + to_plan = db.Column(db.String(20), nullable=False) + to_cycle = db.Column(db.String(10), nullable=False) + to_end_date = db.Column(db.DateTime, nullable=False) + + # 价格计算 + remaining_value = db.Column(db.Numeric(10, 2), nullable=False) + upgrade_amount = db.Column(db.Numeric(10, 2), nullable=False) + actual_amount = db.Column(db.Numeric(10, 2), nullable=False) + + upgrade_type = db.Column(db.String(20), nullable=False) # 'plan_upgrade', 'cycle_change', 'both' + created_at = db.Column(db.DateTime, default=beijing_now) + + # 关系 + order = db.relationship('PaymentOrder', backref='upgrade_record') + + # ============================================ # 模拟盘相关模型 # ============================================ @@ -982,8 +1087,15 @@ def get_user_subscription_safe(user_id): return DefaultSub() -def activate_user_subscription(user_id, plan_type, billing_cycle): - """激活用户订阅""" +def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False): + """激活用户订阅 + + Args: + user_id: 用户ID + plan_type: 套餐类型 + billing_cycle: 计费周期 + extend_from_now: 是否从当前时间开始延长(用于升级场景) + """ try: subscription = UserSubscription.query.filter_by(user_id=user_id).first() if not subscription: @@ -993,7 +1105,9 @@ def activate_user_subscription(user_id, plan_type, billing_cycle): subscription.subscription_type = plan_type subscription.subscription_status = 'active' subscription.billing_cycle = billing_cycle - subscription.start_date = beijing_now() + + if not extend_from_now or not subscription.start_date: + subscription.start_date = beijing_now() if billing_cycle == 'monthly': subscription.end_date = beijing_now() + timedelta(days=30) @@ -1007,6 +1121,195 @@ def activate_user_subscription(user_id, plan_type, billing_cycle): return None +def validate_promo_code(code, plan_name, billing_cycle, amount, user_id): + """验证优惠码 + + Returns: + tuple: (promo_code_obj, error_message) + """ + try: + promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first() + + if not promo: + return None, "优惠码不存在或已失效" + + # 检查有效期 + now = beijing_now() + if now < promo.valid_from: + return None, "优惠码尚未生效" + if now > promo.valid_until: + return None, "优惠码已过期" + + # 检查使用次数 + if promo.max_uses and promo.current_uses >= promo.max_uses: + return None, "优惠码已被使用完" + + # 检查每用户使用次数 + if 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 plan_name not in applicable: + return None, "该优惠码不适用于此套餐" + except: + pass + + # 检查适用周期 + if promo.applicable_cycles: + try: + applicable = json.loads(promo.applicable_cycles) + if 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): + """计算优惠金额""" + try: + if promo_code.discount_type == 'percentage': + discount = amount * (float(promo_code.discount_value) / 100) + else: # fixed_amount + discount = float(promo_code.discount_value) + + # 确保折扣不超过总金额 + return min(discount, amount) + except: + return 0 + + +def calculate_remaining_value(subscription, current_plan): + """计算当前订阅的剩余价值""" + try: + if not subscription or not subscription.end_date: + return 0 + + now = beijing_now() + if subscription.end_date <= now: + return 0 + + days_left = (subscription.end_date - now).days + + if subscription.billing_cycle == 'monthly': + daily_value = float(current_plan.monthly_price) / 30 + else: # yearly + daily_value = float(current_plan.yearly_price) / 365 + + return daily_value * days_left + except: + return 0 + + +def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None): + """计算升级所需价格 + + Returns: + dict: 包含价格计算结果的字典 + """ + try: + # 1. 获取当前订阅 + current_sub = UserSubscription.query.filter_by(user_id=user_id).first() + + # 2. 获取目标套餐 + to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first() + if not to_plan: + return {'error': '目标套餐不存在'} + + # 3. 计算目标套餐价格 + new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price) + + # 4. 如果是新订阅(非升级) + if not current_sub or current_sub.subscription_type == 'free': + result = { + 'is_upgrade': False, + 'new_plan_price': new_price, + 'remaining_value': 0, + 'upgrade_amount': new_price, + 'original_amount': new_price, + 'discount_amount': 0, + 'final_amount': new_price, + 'promo_code': None + } + + # 应用优惠码 + if promo_code: + promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id) + if promo: + discount = calculate_discount(promo, new_price) + result['discount_amount'] = discount + result['final_amount'] = new_price - discount + result['promo_code'] = promo.code + elif error: + result['promo_error'] = error + + return result + + # 5. 升级场景:计算剩余价值 + current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first() + if not current_plan: + return {'error': '当前套餐信息不存在'} + + remaining_value = calculate_remaining_value(current_sub, current_plan) + + # 6. 计算升级差价 + upgrade_amount = max(0, new_price - remaining_value) + + # 7. 判断升级类型 + upgrade_type = 'new' + if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle: + upgrade_type = 'both' + elif current_sub.subscription_type != to_plan_name: + upgrade_type = 'plan_upgrade' + elif current_sub.billing_cycle != to_cycle: + upgrade_type = 'cycle_change' + + result = { + 'is_upgrade': True, + 'upgrade_type': upgrade_type, + 'current_plan': current_sub.subscription_type, + 'current_cycle': current_sub.billing_cycle, + 'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None, + 'new_plan_price': new_price, + 'remaining_value': remaining_value, + 'upgrade_amount': upgrade_amount, + 'original_amount': upgrade_amount, + 'discount_amount': 0, + 'final_amount': upgrade_amount, + 'promo_code': None + } + + # 8. 应用优惠码 + if promo_code and upgrade_amount > 0: + promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id) + if promo: + discount = calculate_discount(promo, upgrade_amount) + result['discount_amount'] = discount + result['final_amount'] = upgrade_amount - discount + result['promo_code'] = promo.code + elif error: + result['promo_error'] = error + + return result + except Exception as e: + return {'error': str(e)} + + def initialize_subscription_plans_safe(): """安全地初始化订阅套餐""" try: @@ -1189,9 +1492,90 @@ def get_subscription_info(): }) +@app.route('/api/promo-code/validate', methods=['POST']) +def validate_promo_code_api(): + """验证优惠码""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + code = data.get('code', '').strip() + plan_name = data.get('plan_name') + billing_cycle = data.get('billing_cycle') + amount = data.get('amount', 0) + + if not code or not plan_name or not billing_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 验证优惠码 + promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id']) + + if error: + return jsonify({ + 'success': False, + 'valid': False, + 'error': error + }) + + # 计算折扣 + discount_amount = calculate_discount(promo, amount) + final_amount = amount - discount_amount + + return jsonify({ + 'success': True, + 'valid': True, + 'promo_code': promo.to_dict(), + 'discount_amount': discount_amount, + 'final_amount': final_amount + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'验证失败: {str(e)}' + }), 500 + + +@app.route('/api/subscription/calculate-price', methods=['POST']) +def calculate_subscription_price(): + """计算订阅价格(支持升级和优惠码)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + to_plan = data.get('to_plan') + to_cycle = data.get('to_cycle') + promo_code = data.get('promo_code', '').strip() or None + + if not to_plan or not to_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 计算价格 + result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code) + + if 'error' in result: + return jsonify({ + 'success': False, + 'error': result['error'] + }), 400 + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'计算失败: {str(e)}' + }), 500 + + @app.route('/api/payment/create-order', methods=['POST']) def create_payment_order(): - """创建支付订单""" + """创建支付订单(支持升级和优惠码)""" try: if 'user_id' not in session: return jsonify({'success': False, 'error': '未登录'}), 401 @@ -1199,23 +1583,21 @@ def create_payment_order(): data = request.get_json() plan_name = data.get('plan_name') billing_cycle = data.get('billing_cycle') + promo_code = data.get('promo_code', '').strip() or None if not plan_name or not billing_cycle: return jsonify({'success': False, 'error': '参数不完整'}), 400 - # 获取套餐信息 - try: - plan = SubscriptionPlan.query.filter_by(name=plan_name, is_active=True).first() - if not plan: - # 如果表不存在,使用默认价格 - prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}} - amount = prices.get(plan_name, {}).get(billing_cycle, 0.01) - else: - amount = plan.monthly_price if billing_cycle == 'monthly' else plan.yearly_price - except: - # 默认价格 - prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}} - amount = prices.get(plan_name, {}).get(billing_cycle, 0.01) + # 计算价格(包括升级和优惠码) + price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code) + + if 'error' in price_result: + return jsonify({'success': False, 'error': price_result['error']}), 400 + + amount = price_result['final_amount'] + original_amount = price_result['original_amount'] + discount_amount = price_result['discount_amount'] + is_upgrade = price_result.get('is_upgrade', False) # 创建订单 try: @@ -1225,10 +1607,52 @@ def create_payment_order(): billing_cycle=billing_cycle, amount=amount ) + + # 添加扩展字段(使用动态属性) + if hasattr(order, 'original_amount') or True: # 兼容性检查 + order.original_amount = original_amount + order.discount_amount = discount_amount + order.is_upgrade = is_upgrade + + # 如果使用了优惠码,关联优惠码 + if promo_code and price_result.get('promo_code'): + promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first() + if promo_obj: + order.promo_code_id = promo_obj.id + + # 如果是升级,记录原套餐信息 + if is_upgrade: + order.upgrade_from_plan = price_result.get('current_plan') + db.session.add(order) db.session.commit() + + # 如果是升级订单,创建升级记录 + if is_upgrade and price_result.get('upgrade_type'): + try: + upgrade_record = SubscriptionUpgrade( + user_id=session['user_id'], + order_id=order.id, + from_plan=price_result['current_plan'], + from_cycle=price_result['current_cycle'], + from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None, + to_plan=plan_name, + to_cycle=billing_cycle, + to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30), + remaining_value=price_result['remaining_value'], + upgrade_amount=price_result['upgrade_amount'], + actual_amount=amount, + upgrade_type=price_result['upgrade_type'] + ) + db.session.add(upgrade_record) + db.session.commit() + except Exception as e: + print(f"创建升级记录失败: {e}") + # 不影响主流程 + except Exception as e: - return jsonify({'success': False, 'error': '订单创建失败'}), 500 + db.session.rollback() + return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500 # 尝试调用真实的微信支付API try: @@ -1420,6 +1844,26 @@ def force_update_order_status(order_id): # 激活用户订阅 activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) + # 记录优惠码使用(如果使用了优惠码) + if hasattr(order, 'promo_code_id') and order.promo_code_id: + try: + promo_usage = PromoCodeUsage( + promo_code_id=order.promo_code_id, + user_id=order.user_id, + order_id=order.id, + original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount, + discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0, + final_amount=order.amount + ) + db.session.add(promo_usage) + + # 更新优惠码使用次数 + promo = PromoCode.query.get(order.promo_code_id) + if promo: + promo.current_uses = (promo.current_uses or 0) + 1 + except Exception as e: + print(f"记录优惠码使用失败: {e}") + db.session.commit() print(f"✅ 订单状态强制更新成功: {old_status} -> paid") diff --git a/migrations/add_promo_code_tables.sql b/migrations/add_promo_code_tables.sql new file mode 100644 index 00000000..d1b0fd4e --- /dev/null +++ b/migrations/add_promo_code_tables.sql @@ -0,0 +1,134 @@ +-- 数据库迁移脚本:添加优惠码和订阅升级相关表 +-- 执行时间:2025-xx-xx +-- 作者:Claude Code +-- 说明:此脚本添加了优惠码、优惠码使用记录和订阅升级记录三张新表,并扩展了 payment_orders 表 + +-- ============================================ +-- 1. 创建优惠码表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `promo_codes` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `code` VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码(唯一)', + `description` VARCHAR(200) DEFAULT NULL COMMENT '优惠码描述', + + -- 折扣类型和值 + `discount_type` VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比) 或 fixed_amount(固定金额)', + `discount_value` DECIMAL(10, 2) NOT NULL COMMENT '折扣值', + + -- 适用范围 + `applicable_plans` VARCHAR(200) DEFAULT NULL COMMENT '适用套餐(JSON格式),如 ["pro", "max"],null表示全部适用', + `applicable_cycles` VARCHAR(50) DEFAULT NULL COMMENT '适用周期(JSON格式),如 ["monthly", "yearly"],null表示全部适用', + `min_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '最低消费金额', + + -- 使用限制 + `max_uses` INT DEFAULT NULL COMMENT '最大使用次数,null表示无限制', + `max_uses_per_user` INT DEFAULT 1 COMMENT '每个用户最多使用次数', + `current_uses` INT DEFAULT 0 COMMENT '当前已使用次数', + + -- 有效期 + `valid_from` DATETIME NOT NULL COMMENT '生效时间', + `valid_until` DATETIME NOT NULL COMMENT '失效时间', + + -- 状态 + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_by` INT DEFAULT NULL COMMENT '创建人(管理员ID)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_code (`code`), + INDEX idx_valid_dates (`valid_from`, `valid_until`), + INDEX idx_is_active (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表'; + + +-- ============================================ +-- 2. 创建优惠码使用记录表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `promo_code_usage` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `promo_code_id` INT NOT NULL COMMENT '优惠码ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `order_id` INT NOT NULL COMMENT '订单ID', + + `original_amount` DECIMAL(10, 2) NOT NULL COMMENT '原价', + `discount_amount` DECIMAL(10, 2) NOT NULL COMMENT '优惠金额', + `final_amount` DECIMAL(10, 2) NOT NULL COMMENT '实付金额', + + `used_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '使用时间', + + FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE, + + INDEX idx_user_id (`user_id`), + INDEX idx_promo_code_id (`promo_code_id`), + INDEX idx_order_id (`order_id`), + INDEX idx_used_at (`used_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表'; + + +-- ============================================ +-- 3. 创建订阅升级记录表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `subscription_upgrades` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `user_id` INT NOT NULL COMMENT '用户ID', + `order_id` INT NOT NULL COMMENT '订单ID', + + -- 原订阅信息 + `from_plan` VARCHAR(20) NOT NULL COMMENT '原套餐', + `from_cycle` VARCHAR(10) NOT NULL COMMENT '原周期', + `from_end_date` DATETIME DEFAULT NULL COMMENT '原到期日', + + -- 新订阅信息 + `to_plan` VARCHAR(20) NOT NULL COMMENT '新套餐', + `to_cycle` VARCHAR(10) NOT NULL COMMENT '新周期', + `to_end_date` DATETIME NOT NULL COMMENT '新到期日', + + -- 价格计算 + `remaining_value` DECIMAL(10, 2) NOT NULL COMMENT '剩余价值', + `upgrade_amount` DECIMAL(10, 2) NOT NULL COMMENT '升级应付金额', + `actual_amount` DECIMAL(10, 2) NOT NULL COMMENT '实际支付金额', + + `upgrade_type` VARCHAR(20) NOT NULL COMMENT '升级类型: plan_upgrade(套餐升级), cycle_change(周期变更), both(都变更)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE, + + INDEX idx_user_id (`user_id`), + INDEX idx_order_id (`order_id`), + INDEX idx_created_at (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅升级/降级记录表'; + + +-- ============================================ +-- 4. 扩展 payment_orders 表(添加新字段) +-- ============================================ +-- 注意:这些字段是可选的扩展,用于记录优惠码和升级信息 +-- 如果字段已存在会报错,可以忽略 + +ALTER TABLE `payment_orders` +ADD COLUMN `promo_code_id` INT DEFAULT NULL COMMENT '使用的优惠码ID' AFTER `remark`, +ADD COLUMN `original_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '原价(使用优惠码前)' AFTER `promo_code_id`, +ADD COLUMN `discount_amount` DECIMAL(10, 2) DEFAULT 0 COMMENT '优惠金额' AFTER `original_amount`, +ADD COLUMN `is_upgrade` BOOLEAN DEFAULT FALSE COMMENT '是否为升级订单' AFTER `discount_amount`, +ADD COLUMN `upgrade_from_plan` VARCHAR(20) DEFAULT NULL COMMENT '从哪个套餐升级' AFTER `is_upgrade`; + +-- 添加外键约束 +ALTER TABLE `payment_orders` +ADD CONSTRAINT `fk_payment_orders_promo_code` +FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE SET NULL; + + +-- ============================================ +-- 5. 插入示例优惠码(供测试使用) +-- ============================================ +-- 10% 折扣优惠码,适用所有套餐和周期 +INSERT INTO `promo_codes` +(`code`, `description`, `discount_type`, `discount_value`, `applicable_plans`, `applicable_cycles`, `min_amount`, `max_uses`, `max_uses_per_user`, `valid_from`, `valid_until`, `is_active`) +VALUES +('WELCOME10', '新用户欢迎优惠 - 10%折扣', 'percentage', 10.00, NULL, NULL, NULL, NULL, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE), +('ANNUAL20', '年付专享 - 20%折扣', 'percentage', 20.00, NULL, '["yearly"]', NULL, 100, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE), +('SUMMER50', '夏季促销 - 减免50元', 'fixed_amount', 50.00, '["max"]', NULL, 100.00, 50, 1, NOW(), DATE_ADD(NOW(), INTERVAL 3 MONTH), TRUE); + +-- 完成 +SELECT 'Migration completed successfully!' AS status; diff --git a/src/components/Subscription/SubscriptionContent.js b/src/components/Subscription/SubscriptionContent.js index 79c9b351..c621516e 100644 --- a/src/components/Subscription/SubscriptionContent.js +++ b/src/components/Subscription/SubscriptionContent.js @@ -29,6 +29,9 @@ import { Td, Heading, Collapse, + Input, + InputGroup, + InputRightElement, } from '@chakra-ui/react'; import React, { useState, useEffect } from 'react'; import { logger } from '../../utils/logger'; @@ -85,6 +88,13 @@ export default function SubscriptionContent() { const [forceUpdating, setForceUpdating] = useState(false); const [openFaqIndex, setOpenFaqIndex] = useState(null); + // 优惠码相关state + const [promoCode, setPromoCode] = useState(''); + const [promoCodeApplied, setPromoCodeApplied] = useState(false); + const [promoCodeError, setPromoCodeError] = useState(''); + const [validatingPromo, setValidatingPromo] = useState(false); + const [priceInfo, setPriceInfo] = useState(null); // 价格信息(包含升级计算) + // 加载订阅套餐数据 useEffect(() => { fetchSubscriptionPlans(); @@ -149,7 +159,88 @@ export default function SubscriptionContent() { } }; - const handleSubscribe = (plan) => { + // 计算价格(包含升级和优惠码) + const calculatePrice = async (plan, cycle, promoCodeValue = null) => { + try { + const response = await fetch('/api/subscription/calculate-price', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + to_plan: plan.name, + to_cycle: cycle, + promo_code: promoCodeValue || null + }) + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + setPriceInfo(data.data); + return data.data; + } + } + return null; + } catch (error) { + logger.error('SubscriptionContent', 'calculatePrice', error); + return null; + } + }; + + // 验证优惠码 + const handleValidatePromoCode = async () => { + if (!promoCode.trim()) { + setPromoCodeError('请输入优惠码'); + return; + } + + if (!selectedPlan) { + setPromoCodeError('请先选择套餐'); + return; + } + + setValidatingPromo(true); + setPromoCodeError(''); + + try { + // 重新计算价格,包含优惠码 + const result = await calculatePrice(selectedPlan, selectedCycle, promoCode); + + if (result && !result.promo_error) { + setPromoCodeApplied(true); + toast({ + title: '优惠码已应用', + description: `节省 ¥${result.discount_amount.toFixed(2)}`, + status: 'success', + duration: 3000, + isClosable: true, + }); + } else { + setPromoCodeError(result?.promo_error || '优惠码无效'); + setPromoCodeApplied(false); + } + } catch (error) { + setPromoCodeError('验证失败,请重试'); + setPromoCodeApplied(false); + } finally { + setValidatingPromo(false); + } + }; + + // 移除优惠码 + const handleRemovePromoCode = async () => { + setPromoCode(''); + setPromoCodeApplied(false); + setPromoCodeError(''); + // 重新计算价格(不含优惠码) + if (selectedPlan) { + await calculatePrice(selectedPlan, selectedCycle, null); + } + }; + + const handleSubscribe = async (plan) => { if (!user) { toast({ title: '请先登录', @@ -178,6 +269,10 @@ export default function SubscriptionContent() { ); setSelectedPlan(plan); + + // 计算价格(包含升级判断) + await calculatePrice(plan, selectedCycle, promoCodeApplied ? promoCode : null); + onPaymentModalOpen(); }; @@ -186,7 +281,7 @@ export default function SubscriptionContent() { setLoading(true); try { - const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price; + const price = priceInfo?.final_amount || (selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price); // 🎯 追踪支付发起 subscriptionEvents.trackPaymentInitiated({ @@ -205,7 +300,8 @@ export default function SubscriptionContent() { credentials: 'include', body: JSON.stringify({ plan_name: selectedPlan.name, - billing_cycle: selectedCycle + billing_cycle: selectedCycle, + promo_code: promoCodeApplied ? promoCode : null }) }); @@ -488,6 +584,27 @@ export default function SubscriptionContent() { return `年付节省 ${percentage}%`; }; + // 获取按钮文字(根据用户当前订阅判断是升级还是新订阅) + const getButtonText = (plan, user) => { + if (!user || user.subscription_type === 'free') { + return `选择 ${plan.display_name}`; + } + + // 判断是否为升级 + const planLevels = { 'free': 0, 'pro': 1, 'max': 2 }; + const currentLevel = planLevels[user.subscription_type] || 0; + const targetLevel = planLevels[plan.name] || 0; + + if (targetLevel > currentLevel) { + return `升级至 ${plan.display_name}`; + } else if (targetLevel < currentLevel) { + return `切换至 ${plan.display_name}`; + } else { + // 同级别,可能是切换周期 + return `切换至 ${plan.display_name}`; + } + }; + // 统一的功能列表定义 - 基于商业定价(10月15日)文档 const allFeatures = [ // 新闻催化分析模块 @@ -838,7 +955,8 @@ export default function SubscriptionContent() { onClick={() => handleSubscribe(plan)} isDisabled={ user?.subscription_type === plan.name && - user?.subscription_status === 'active' + user?.subscription_status === 'active' && + user?.billing_cycle === selectedCycle } _hover={{ transform: 'scale(1.02)', @@ -846,9 +964,10 @@ export default function SubscriptionContent() { transition="all 0.2s" > {user?.subscription_type === plan.name && - user?.subscription_status === 'active' - ? '✓ 已订阅' - : `选择 ${plan.display_name}` + user?.subscription_status === 'active' && + user?.billing_cycle === selectedCycle + ? '✓ 当前套餐' + : getButtonText(plan, user) } @@ -1084,14 +1203,62 @@ export default function SubscriptionContent() { 计费周期: {selectedCycle === 'monthly' ? '按月付费' : '按年付费'} - - - 应付金额: - - ¥{getCurrentPrice(selectedPlan).toFixed(2)} + + {/* 价格明细 */} + + + {priceInfo && priceInfo.is_upgrade && ( + + + + + {priceInfo.upgrade_type === 'plan_upgrade' ? '套餐升级' : + priceInfo.upgrade_type === 'cycle_change' ? '周期变更' : '套餐和周期调整'} + + + + + 当前订阅: {priceInfo.current_plan === 'pro' ? 'Pro版' : 'Max版'} ({priceInfo.current_cycle === 'monthly' ? '月付' : '年付'}) + + + 剩余价值: + ¥{priceInfo.remaining_value.toFixed(2)} + + + + )} + + + + {priceInfo && priceInfo.is_upgrade ? '新套餐价格:' : '套餐价格:'} + + + ¥{priceInfo ? priceInfo.new_plan_price.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)} - {getSavingsText(selectedPlan) && ( + + {priceInfo && priceInfo.is_upgrade && priceInfo.remaining_value > 0 && ( + + 已付剩余抵扣: + -¥{priceInfo.remaining_value.toFixed(2)} + + )} + + {priceInfo && priceInfo.discount_amount > 0 && ( + + 优惠码折扣: + -¥{priceInfo.discount_amount.toFixed(2)} + + )} + + + + 实付金额: + + ¥{priceInfo ? priceInfo.final_amount.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)} + + + {getSavingsText(selectedPlan) && !priceInfo?.is_upgrade && ( {getSavingsText(selectedPlan)} @@ -1104,6 +1271,53 @@ export default function SubscriptionContent() { )} + {/* 优惠码输入 */} + {selectedPlan && ( + + + { + setPromoCode(e.target.value.toUpperCase()); + setPromoCodeError(''); + }} + size="md" + isDisabled={promoCodeApplied} + /> + + + {promoCodeError && ( + + {promoCodeError} + + )} + {promoCodeApplied && priceInfo && ( + + + + 优惠码已应用!节省 ¥{priceInfo.discount_amount.toFixed(2)} + + + + )} + + )} +