From 71a613ff5f75cae39a8bd26da95fe14d6a2eb7f7 Mon Sep 17 00:00:00 2001 From: coder Date: Tue, 14 Apr 2026 18:58:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=8F=B7=E3=80=81=E9=82=80=E8=AF=B7=E5=A5=BD=E5=8F=8B=E3=80=81?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用户模型添加:手机号、邀请码、邀请统计、邮件通知设置 - 邀请好友系统:专属邀请码、邀请奖励(¥5/人) - 邮件通知:翻译完成通知(含附件)、欢迎邮件、到期提醒 - 新增模型:UserInvitation, InviteRewardConfig, EmailNotification, EmailTemplateConfig - 个人中心添加:手机号绑定、通知设置、邀请好友模块 - email_service.py:邮件发送服务(支持附件) 新用户注册奖励:¥2 邀请人奖励:¥5/人 --- app.py | 138 ++++++++++++++++++++++++++- email_service.py | 210 +++++++++++++++++++++++++++++++++++++++++ models.py | 196 ++++++++++++++++++++++++++++++++++++++ templates/profile.html | 185 +++++++++++++++++++++++++++++++++++- 4 files changed, 725 insertions(+), 4 deletions(-) create mode 100644 email_service.py diff --git a/app.py b/app.py index 3f219ae..4fb1ca8 100644 --- a/app.py +++ b/app.py @@ -15,7 +15,9 @@ from werkzeug.utils import secure_filename from config import * from models import (db, User, Translation, TranslationCache, GuestTranslation, DataPackage, UserPackage, DynamicConfig, UserRecharge, UserRefund, - MembershipPurchase, AccountTransaction, MembershipPlanConfig, UserTypeConfig) + MembershipPurchase, AccountTransaction, MembershipPlanConfig, UserTypeConfig, + UserInvitation, InviteRewardConfig, EmailNotification, EmailTemplateConfig) +from email_service import email_service from services import TranslationService, CacheService, TranslationTask from admin import admin_bp @@ -591,12 +593,15 @@ def login(): def register(): """注册""" if request.method == 'GET': - return render_template('register.html') + # 检查邀请码参数 + invite_code = request.args.get('invite', None) + return render_template('register.html', invite_code=invite_code) data = request.json username = data.get('username') email = data.get('email') password = data.get('password') + invite_code = data.get('invite_code', None) # 邀请码 # 检查用户是否存在 if User.query.filter_by(username=username).first(): @@ -608,11 +613,76 @@ def register(): # 创建用户 user = User(username=username, email=email, user_type='free') user.set_password(password) + + # 生成邀请码(6位随机字符) + import random + import string + user.invite_code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + + # 处理邀请 + if invite_code: + inviter = User.query.filter_by(invite_code=invite_code).first() + if inviter: + user.invited_by = inviter.id + + # 创建邀请记录 + invitation = UserInvitation( + inviter_id=inviter.id, + invite_code=invite_code, + invitee_id=user.id, + invitee_email=email, + reward_amount=5.0, + status='registered', + registered_at=datetime.utcnow() + ) + db.session.add(invitation) + + # 给邀请人奖励 + inviter.invite_count += 1 + inviter.invite_rewards += 5.0 + inviter.balance += 5.0 + + # 给被邀请人奖励 + user.balance += 2.0 + + # 创建流水 + inviter_tx = AccountTransaction( + user_id=inviter.id, + transaction_type='invite_reward', + amount=5.0, + balance_before=inviter.balance - 5.0, + balance_after=inviter.balance, + description=f'邀请用户{username}注册奖励' + ) + db.session.add(inviter_tx) + + user_tx = AccountTransaction( + user_id=user.id, + transaction_type='invite_bonus', + amount=2.0, + balance_before=0, + balance_after=2.0, + description='新用户注册奖励' + ) + db.session.add(user_tx) + + # 发送邀请奖励邮件 + if inviter.email_notify: + email_service.send_invite_reward(inviter.email, inviter.username, 5.0, inviter.invite_count) + db.session.add(user) db.session.commit() session['user_id'] = user.id - return jsonify({'success': True, 'user': user.to_dict()}) + + # 发送欢迎邮件 + email_service.send_welcome_email(user.email, user.username, user.invite_code) + + return jsonify({ + 'success': True, + 'user': user.to_dict(), + 'invite_reward': user.balance > 0 # 是否获得邀请奖励 + }) @app.route('/logout') @@ -775,6 +845,68 @@ def request_refund(): }) +@app.route('/api/profile/settings', methods=['POST']) +def update_settings(): + """更新账户设置""" + user = get_current_user() + if not user: + return jsonify({'error': '请登录'}), 401 + + data = request.json + + # 手机号 + if 'phone' in data: + phone = data.get('phone', '') + if phone and len(phone) >= 10: + user.phone = phone + user.phone_verified = False + + # 通知设置 + if 'notify_on_complete' in data: + user.notify_on_complete = data.get('notify_on_complete', True) + if 'notify_on_expire' in data: + user.notify_on_expire = data.get('notify_on_expire', True) + if 'email_notify' in data: + user.email_notify = data.get('email_notify', True) + + db.session.commit() + + return jsonify({ + 'success': True, + 'user': user.to_dict() + }) + + +@app.route('/api/profile/invitations') +def get_invitations(): + """获取邀请记录""" + user = get_current_user() + if not user: + return jsonify({'error': '请登录'}), 401 + + invitations = UserInvitation.query.filter_by(inviter_id=user.id)\ + .order_by(UserInvitation.created_at.desc()).limit(20).all() + + return jsonify({ + 'success': True, + 'invitations': [inv.to_dict() for inv in invitations] + }) + + +@app.route('/api/invite/') +def check_invite(invite_code): + """检查邀请码""" + inviter = User.query.filter_by(invite_code=invite_code).first() + if not inviter: + return jsonify({'valid': False, 'error': '邀请码无效'}) + + return jsonify({ + 'valid': True, + 'inviter': inviter.username, + 'reward': 5 # 被邀请人奖励 + }) + + # ==================== 初始化 ==================== def init_app(): """初始化应用""" diff --git a/email_service.py b/email_service.py new file mode 100644 index 0000000..b95a8d1 --- /dev/null +++ b/email_service.py @@ -0,0 +1,210 @@ +""" +邮件发送服务 +支持SMTP邮件发送、附件发送、模板渲染 +""" + +import os +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +from datetime import datetime +from flask import current_app + + +class EmailService: + """邮件发送服务""" + + def __init__(self, smtp_host=None, smtp_port=None, smtp_user=None, smtp_pass=None): + self.smtp_host = smtp_host or os.environ.get('SMTP_HOST', 'mail.tphai.com') + self.smtp_port = int(smtp_port or os.environ.get('SMTP_PORT', '587')) + self.smtp_user = smtp_user or os.environ.get('SMTP_USER', 'favor@tphai.com') + self.smtp_pass = smtp_pass or os.environ.get('SMTP_PASS', 'favor@!') + + def send_email(self, to_email, subject, body, attachment_path=None, attachment_name=None): + """发送邮件(支持附件)""" + try: + # 创建邮件对象 + msg = MIMEMultipart() + msg['From'] = self.smtp_user + msg['To'] = to_email + msg['Subject'] = subject + msg['Date'] = datetime.now().strftime('%a, %d %b %Y %H:%M:%S +0800') + msg['Reply-To'] = to_email + + # 正文 + msg.attach(MIMEText(body, 'html', 'utf-8')) + + # 附件 + if attachment_path and os.path.exists(attachment_path): + with open(attachment_path, 'rb') as f: + part = MIMEBase('application', 'octet-stream') + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header('Content-Disposition', 'attachment', + filename=attachment_name or os.path.basename(attachment_path)) + msg.attach(part) + + # 发送 + server = smtplib.SMTP(self.smtp_host, self.smtp_port) + server.ehlo() + server.login(self.smtp_user, self.smtp_pass) + server.sendmail(self.smtp_user, to_email, msg.as_string()) + server.quit() + + return True, "发送成功" + + except Exception as e: + return False, str(e) + + def send_translation_complete(self, user_email, username, filename, output_path, translation_id): + """翻译完成通知""" + subject = f"【PDF翻译助手】翻译完成 - {filename}" + + body = f""" + + +
+

📄 PDF翻译助手

+
+
+

您好,{username}!

+

您的翻译任务已完成:

+ +
+

文件:{filename}

+

状态:✅ 完成

+

时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}

+
+ +

翻译结果已作为附件发送,您也可以登录网站查看详情。

+ +
+ + 查看翻译结果 + +
+ +
+

+ 此邮件由系统自动发送,请勿回复。
+ PDF翻译助手 - 让翻译更简单 +

+
+ + + """ + + # 附件名称 + attachment_name = f"{filename}_translated.md" + + return self.send_email(user_email, subject, body, output_path, attachment_name) + + def send_welcome_email(self, user_email, username, invite_code=None): + """欢迎邮件""" + subject = "欢迎加入PDF翻译助手" + + body = f""" + + +
+

📄 PDF翻译助手

+
+
+

您好,{username}!

+

欢迎加入PDF翻译助手!您已获得:

+ +
+

✅ 每日免费翻译 10 次

+

✅ 单文件最大 50 页

+

✅ 翻译历史记录

+

✅ 不满意重新翻译

+
+ + {"

您的专属邀请码:" + invite_code + ",分享给好友可获得奖励!

" if invite_code else ""} + +
+ + 开始使用 + +
+
+ + + """ + + return self.send_email(user_email, subject, body) + + def send_expire_reminder(self, user_email, username, expire_date, user_type): + """会员到期提醒""" + subject = "【PDF翻译助手】会员即将到期提醒" + + body = f""" + + +
+

📄 PDF翻译助手

+
+
+

您好,{username}!

+ +
+

⚠️ 您的会员即将到期

+

会员类型:{user_type}

+

到期时间:{expire_date}

+
+ +

到期后将降级为免费用户,每日翻译次数限制为10次。续费可继续享受会员权益。

+ +
+ + 续费会员 + +
+
+ + + """ + + return self.send_email(user_email, subject, body) + + def send_invite_reward(self, user_email, username, reward_amount, invitee_count): + """邀请奖励通知""" + subject = f"【PDF翻译助手】邀请奖励 - ¥{reward_amount}" + + body = f""" + + +
+

🎉 邀请奖励已发放

+
+
+

您好,{username}!

+ +
+

奖励金额:¥{reward_amount}

+

累计邀请:{invitee_count}人

+
+ +

奖励已发放到您的账户余额,可用于购买会员或翻译服务。

+ +
+ + 查看余额 + +
+
+ + + """ + + return self.send_email(user_email, subject, body) + + +# 全局邮件服务实例 +email_service = EmailService() \ No newline at end of file diff --git a/models.py b/models.py index 9fdfde1..ccc2acd 100644 --- a/models.py +++ b/models.py @@ -41,8 +41,25 @@ class User(db.Model): # 余额 balance = db.Column(db.Float, default=0.0) # 账户余额(元) + # 手机号 + phone = db.Column(db.String(20), nullable=True) # 手机号 + phone_verified = db.Column(db.Boolean, default=False) # 手机号已验证 + + # 邀请系统 + invite_code = db.Column(db.String(10), unique=True, nullable=True) # 用户专属邀请码 + invited_by = db.Column(db.Integer, nullable=True) # 邀请人ID + invite_count = db.Column(db.Integer, default=0) # 已邀请人数 + invite_rewards = db.Column(db.Float, default=0.0) # 邀请奖励金额 + + # 邮件通知设置 + email_notify = db.Column(db.Boolean, default=True) # 邮件通知开关 + notify_on_complete = db.Column(db.Boolean, default=True) # 翻译完成通知 + notify_on_expire = db.Column(db.Boolean, default=True) # 会员到期提醒 + # 关系 translations = db.relationship('Translation', backref='user', lazy=True) + invitations = db.relationship('UserInvitation', backref='inviter', lazy=True, + foreign_keys='UserInvitation.inviter_id') recharges = db.relationship('UserRecharge', backref='user', lazy=True) refunds = db.relationship('UserRefund', backref='user', lazy=True) @@ -103,11 +120,19 @@ class User(db.Model): 'id': self.id, 'username': self.username, 'email': self.email, + 'phone': self.phone, + 'phone_verified': self.phone_verified, 'user_type': self.user_type, 'is_vip': self.is_vip(), 'is_admin': self.is_admin, 'is_active': self.is_active, 'balance': self.balance, + 'invite_code': self.invite_code, + 'invite_count': self.invite_count, + 'invite_rewards': self.invite_rewards, + 'email_notify': self.email_notify, + 'notify_on_complete': self.notify_on_complete, + 'notify_on_expire': self.notify_on_expire, 'daily_count': self.daily_count, 'total_count': self.total_count, 'created_at': self.created_at.isoformat() if self.created_at else None, @@ -709,4 +734,175 @@ class AccountTransaction(db.Model): 'related_type': self.related_type, 'description': self.description, 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + +# ==================== 用户邀请记录 ==================== +class UserInvitation(db.Model): + """用户邀请记录""" + __tablename__ = 'user_invitations' + + id = db.Column(db.Integer, primary_key=True) + + # 邀请人 + inviter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + invite_code = db.Column(db.String(10), nullable=False) # 使用的邀请码 + + # 被邀请人 + invitee_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # 注册后关联 + invitee_email = db.Column(db.String(120), nullable=True) # 被邀请人邮箱(注册前) + + # 奖励 + reward_amount = db.Column(db.Float, default=0.0) # 奖励金额 + reward_given = db.Column(db.Boolean, default=False) # 是否已发放奖励 + + # 状态 + status = db.Column(db.String(20), default='pending') # pending, registered, rewarded + + # 时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + registered_at = db.Column(db.DateTime, nullable=True) # 被邀请人注册时间 + rewarded_at = db.Column(db.DateTime, nullable=True) # 奖励发放时间 + + # 关系 + invitee = db.relationship('User', backref=db.backref('invited_by_record', lazy=True), + foreign_keys=[invitee_id]) + + def to_dict(self): + return { + 'id': self.id, + 'inviter_id': self.inviter_id, + 'invite_code': self.invite_code, + 'invitee_id': self.invitee_id, + 'invitee_email': self.invitee_email, + 'reward_amount': self.reward_amount, + 'reward_given': self.reward_given, + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + +# ==================== 邀请奖励配置 ==================== +class InviteRewardConfig(db.Model): + """邀请奖励配置""" + __tablename__ = 'invite_reward_config' + + id = db.Column(db.Integer, primary_key=True) + + # 奖励规则 + reward_amount = db.Column(db.Float, default=5.0) # 邀请奖励金额(元) + invitee_bonus = db.Column(db.Float, default=2.0) # 被邀请人 bonus(元) + min_invitee_user_type = db.Column(db.String(20), default='free') # 被邀请人最低等级才能奖励 + + # 限制 + max_daily_invites = db.Column(db.Integer, default=10) # 每日最大邀请次数 + max_total_invites = db.Column(db.Integer, default=100) # 总最大邀请次数 + + # 状态 + is_active = db.Column(db.Boolean, default=True) + + # 时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'reward_amount': self.reward_amount, + 'invitee_bonus': self.invitee_bonus, + 'min_invitee_user_type': self.min_invitee_user_type, + 'max_daily_invites': self.max_daily_invites, + 'max_total_invites': self.max_total_invites, + 'is_active': self.is_active, + } + + +# ==================== 邮件通知记录 ==================== +class EmailNotification(db.Model): + """邮件通知记录""" + __tablename__ = 'email_notifications' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + # 邮件信息 + email_to = db.Column(db.String(120), nullable=False) # 收件人 + email_type = db.Column(db.String(30), nullable=False) # complete, expire, invoice, welcome + + # 附件 + has_attachment = db.Column(db.Boolean, default=False) + attachment_path = db.Column(db.String(255), nullable=True) # 附件文件路径 + attachment_name = db.Column(db.String(100), nullable=True) # 附件显示名称 + + # 内容 + subject = db.Column(db.String(200), nullable=True) + body_preview = db.Column(db.Text, nullable=True) # 正文预览 + + # 状态 + status = db.Column(db.String(20), default='pending') # pending, sent, failed + error_message = db.Column(db.String(255), nullable=True) + + # 关联 + related_id = db.Column(db.Integer, nullable=True) # 关联ID(翻译ID、订单ID等) + related_type = db.Column(db.String(30), nullable=True) # 关联类型 + + # 时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + sent_at = db.Column(db.DateTime, nullable=True) + + # 关系 + user = db.relationship('User', backref=db.backref('email_notifications', lazy=True)) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'email_to': self.email_to, + 'email_type': self.email_type, + 'has_attachment': self.has_attachment, + 'subject': self.subject, + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'sent_at': self.sent_at.isoformat() if self.sent_at else None, + } + + +# ==================== 邮件模板配置 ==================== +class EmailTemplateConfig(db.Model): + """邮件模板配置""" + __tablename__ = 'email_template_config' + + id = db.Column(db.Integer, primary_key=True) + + template_key = db.Column(db.String(50), unique=True, nullable=False) # complete, expire, invoice, welcome, invite + template_name = db.Column(db.String(100), nullable=False) # 显示名称 + + # 模板内容 + subject_template = db.Column(db.String(200), nullable=False) # 主题模板 + body_template = db.Column(db.Text, nullable=False) # 正文模板(支持变量) + + # 支持变量说明 + variables = db.Column(db.Text, nullable=True) # JSON: ["username", "filename", ...] + + # 状态 + is_active = db.Column(db.Boolean, default=True) + + # 时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def get_variables(self): + import json + try: + return json.loads(self.variables) if self.variables else [] + except: + return [] + + def to_dict(self): + return { + 'id': self.id, + 'template_key': self.template_key, + 'template_name': self.template_name, + 'subject_template': self.subject_template, + 'is_active': self.is_active, } \ No newline at end of file diff --git a/templates/profile.html b/templates/profile.html index ea99ab4..0724237 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -134,7 +134,7 @@ -
+
🎫 会员购买记录
@@ -157,6 +157,111 @@
+ + +
+
+
⚙️ 账户设置
+
+
+
+ +
+
+
📱 手机号
+
+ {% if user.phone %} +

已绑定:{{ user.phone }} + {% if user.phone_verified %}已验证{% endif %} +

+ + {% else %} +

未绑定手机号

+ + {% endif %} +
+
+
+ + +
+
+
📧 邮件通知
+
+ + +
+
+ + +
+ 通知发送至:{{ user.email }} +
+
+
+
+
+ + +
+
+
🎁 邀请好友
+
+
+
+
+
+
您的专属邀请码
+
+ + +
+

+ 分享邀请码给好友,好友注册后您可获得 ¥5 奖励! +

+
+
+
+
+
邀请统计
+
+
+
{{ user.invite_count }}
+ 已邀请 +
+
+
¥{{ "%.2f"|format(user.invite_rewards) }}
+ 累计奖励 +
+
+
+
+
+ + +
+
邀请记录
+ + + + + + + + + + + + +
被邀请人注册时间奖励状态
+
+
+
@@ -340,10 +445,88 @@ // 初始化 loadTransactions(); loadPurchases(); + loadInviteRecords(); document.getElementById('transactionFilter').addEventListener('change', function() { loadTransactions(this.value); }); + + // 加载邀请记录 + function loadInviteRecords() { + fetch('/api/profile/invitations') + .then(r => r.json()) + .then(data => { + const tbody = document.getElementById('inviteRecords'); + if (data.invitations && data.invitations.length > 0) { + tbody.innerHTML = data.invitations.map(inv => { + const statusMap = { 'pending': '待注册', 'registered': '已注册', 'rewarded': '已奖励' }; + const statusClass = { 'pending': 'text-muted', 'registered': 'text-info', 'rewarded': 'text-success' }; + return ` + ${inv.invitee_email || '用户' + inv.invitee_id} + ${inv.created_at} + ¥${inv.reward_amount.toFixed(2)} + ${statusMap[inv.status]} + `; + }).join(''); + } else { + tbody.innerHTML = '暂无邀请记录'; + } + }); + } + + // 复制邀请码 + function copyInviteCode() { + const code = document.getElementById('inviteCode').value; + navigator.clipboard.writeText(code).then(() => { + alert('邀请码已复制:' + code); + }); + } + + // 更新通知设置 + function updateNotifySettings() { + const notifyComplete = document.getElementById('notifyComplete').checked; + const notifyExpire = document.getElementById('notifyExpire').checked; + + fetch('/api/profile/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + notify_on_complete: notifyComplete, + notify_on_expire: notifyExpire + }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + // 不刷新页面,只显示提示 + console.log('设置已更新'); + } + }); + } + + // 绑定手机号 + function showPhoneModal() { + // 简单处理,直接弹输入框 + const phone = prompt('请输入手机号:'); + if (phone && phone.length >= 10) { + fetch('/api/profile/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone: phone }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + alert('手机号已绑定'); + location.reload(); + } else { + alert('绑定失败:' + data.error); + } + }); + } else if (phone) { + alert('手机号格式不正确'); + } + } \ No newline at end of file