unipay/unipay/providers/transfer.py

342 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import re
from random import randint
from typing import Any, Dict, Optional
import urllib
import poplib
from appPublic.log import debug, exception
from appPublic.dictObject import DictObject
from appPublic.timeUtils import curDateString, timestampstr
from ahserver.serverenv import ServerEnv
from email.parser import Parser
from email.header import decode_header
from email.utils import parseaddr
from sqlor.dbpools import DBPools
from ..core import Gateway
def guess_charset(msg):
charset = msg.get_charset()
if charset is None:
content_type = msg.get('Content-Type', '').lower()
pos = content_type.find('charset=')
if pos >= 0:
charset = content_type[pos + 8:].strip()
return charset
class EmailClient:
def __init__(self, pop3_server, emailaddress, password):
self.client = poplib.POP3(pop3_server)
self.client.user(emailaddress)
self.client.pass_(password)
def stat(self):
return self.client.stat()
def mail_list(self):
resp, mails, octets = self.client.list()
def get_mail(self, index):
resp, lines, octets = self.client.retr(index)
debug(f'{resp=}, {octets=}')
msg_content = b'\r\n'.join(lines).decode('utf-8')
msg = Parser().parsestr(msg_content)
mail = {
"mailfrom": msg.get('From'),
"mailto": msg.get('To'),
"subject": msg.get('Subject'),
"date": msg.get('Date'),
"body": self.get_body(msg)
}
return DictObject(**mail)
def get_body(self, msg):
if msg.is_multipart():
parts = msg.get_payload()
content = ''
for part in parts:
content += self.get_body(part)
return content
else:
content_type = msg.get_content_type()
content = msg.get_payload(decode=True)
charset = guess_charset(msg)
if charset:
content = content.decode(charset)
else:
content = content.decode('utf-8')
return content
class TransferGateway(Gateway):
def __init__(self, from_mail="", pop3server="", email="", password="", account_no="", account_name="", bank_name="", bank_branch=""):
self.from_mail = from_mail
self.pop3server = pop3server
self.email = email
self.password = password
self.account_no = account_no
self.account_name = account_name
self.bank_name = bank_name
self.bank_branch = bank_branch
self.running = False
async def new_tcode(self, sor):
tcode = self.gen_mailcode()
sql = "select * from transfercode where tcode=${tcode}$ and status='0'"
while True:
recs = await sor.sqlExe(sql, {'tcode': tcode})
if len(recs) == 0:
break
tcode = self.gen_mailcode()
return tcode
async def create_payment(self, payload: Dict[str, Any]) -> str:
"""
返回一个可以在 H5 里直接重定向的支付宝支付 URL
"""
ns = {
"id": payload.out_trade_no,
"customerid": payload.customerid,
"amount": payload.amount,
"curdate": curDateString(),
"curtime": timestampstr(),
"status": '0'
}
ns = DictObject(**ns)
ns.account_no = self.account_no
ns.account_name = self.account_name
ns.bank_name = self.bank_name
ns.bank_branch = self.bank_branch
env = payload.request._run_ns
db = DBPools()
dbname = env.get_module_dbname('unipay')
async with db.sqlorContext(dbname) as sor:
sql = "select * from transfercode where tcode=${tcode}$ and status='0'"
ns.tcode = await self.new_tcode(sor)
await sor.C('transfercode', ns.copy())
url = env.entire_url('transfer_info.ui')
query_str = urllib.parse.urlencode(ns)
return f'{url}?{query_str}'
return None
def get_transfer_data(self, mail):
debug(f'get_transfer_data: from={mail.mailfrom} to={mail.mailto} expect_to={self.email}')
if mail.mailfrom != '95555@message.cmbchina.com':
debug(f'get_transfer_data: wrong sender')
return None
if mail.mailto != self.email:
debug(f'get_transfer_data: mailto mismatch: {mail.mailto} != {self.email}')
return None
if not mail.body.startswith('动账业务通知'):
debug(f'get_transfer_data: body not start with 动账业务通知, head={mail.body[:30]}')
return None
ns = DictObject()
match = re.search(r'交易金额\s*[:]?\s*(\d+(?:\.\d+)?)', mail.body)
if match:
ns.amount = float(match.group(1))
debug(f'get_transfer_data: amount={ns.amount}')
match = re.search(r'摘要[:]\s*(\d{7})(?!\d)', mail.body)
if match:
ns.code = match.group(1)
debug(f'get_transfer_data: code={ns.code}')
if not hasattr(ns, 'amount'):
debug(f'get_transfer_data: no amount found, body={mail.body[:200]}')
return None
if not hasattr(ns, 'code'):
debug(f'get_transfer_data: no code found, body={mail.body[:200]}')
return None
return ns
def gen_mailcode(self):
c = '1'
for i in range(6):
c += str(randint(0,9))
return c
async def run(self, app):
"""后台轮询邮箱,匹配转账码并完成充值入账"""
debug(f'mail check loop will starting')
self.running = True
return
while self.running:
debug(f'mail check loop running')
try:
ec = EmailClient(self.pop3server, self.email, self.password)
count, _ = ec.stat()
if count == 0:
await asyncio.sleep(30)
continue
for i in range(count, 0, -1):
try:
mail = ec.get_mail(i)
if mail.mailfrom != '95555@message.cmbchina.com':
debug(f'ignored mail from {mail.mailfrom=}')
continue
if not mail.body.startswith('动账业务通知'):
debug(f'ignored mail from {mail.mailfrom=} not start with 动账业务通知{mail.body=}')
continue
data = self.get_transfer_data(mail)
if data is None:
debug(f'ignored mail from {mail.mailfrom=} get data error {mail.body=}')
continue
debug(f'transfer mail matched: code={data.code}, amount={data.amount}')
env = ServerEnv()
db = DBPools()
dbname = env.get_module_dbname('unipay')
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('transfercode', {'tcode': data.code, 'status': '0'})
if len(recs) < 1:
debug(f'tcode {data.code} not found or already processed')
continue
tc = recs[0]
if abs(float(tc.amount) - data.amount) > 0.01:
debug(f'amount mismatch: tc={tc.amount}, mail={data.amount}')
continue
biz_date = await env.get_business_date(sor)
await sor.U('transfercode', {'id': tc.id, 'status': '1'})
recs2 = await sor.R('payment_log', {'id': tc.id, 'payment_status': '0'})
if len(recs2) > 0:
plog = recs2[0]
await env.recharge_accounting(sor,
plog.customerid,
'RECHARGE',
plog.id,
biz_date,
plog.amount_total,
plog.pay_feerate)
await sor.U('payment_log', {
'id': plog.id,
'payment_status': '1',
'payed_timestamp': timestampstr()
})
debug(f'transfer recharge done: {tc.id}, amount={data.amount}')
ec.client.dele(i)
except Exception as e2:
exception(f'transfer mail[{i}] error: {e2}')
ec.client.quit()
except Exception as e:
exception(f'transfer poll error: {e}')
await asyncio.sleep(30)
async def check_transfer(self, tcode: str, env):
"""扫描最近2天邮件查找指定转账码并完成入账。
返回: (title, message)"""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(days=2)
ec = EmailClient(self.pop3server, self.email, self.password)
count, _ = ec.stat()
debug(f'check_transfer: tcode={tcode}, total {count} mails, cutoff={cutoff}')
db = DBPools()
dbname = env.get_module_dbname('unipay')
# 检查是否已入账
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('transfercode', {'tcode': tcode})
if recs and recs[0].status == '1':
tc = recs[0]
return ('已入账', '转账码 ' + tcode + ' 已入账 ' + str(tc.amount) + '')
log_lines = []
matched = None
scanned = 0
for i in range(count, 0, -1):
try:
mail = ec.get_mail(i)
scanned += 1
idx = str(i)
# 日期过滤
mail_date_str = getattr(mail, 'date', None)
skip_date = False
if mail_date_str:
try:
from email.utils import parsedate_to_datetime
mail_date = parsedate_to_datetime(mail_date_str)
if mail_date < cutoff:
skip_date = True
except:
pass
if skip_date:
debug('mail[' + idx + '] date=' + str(mail_date_str) + ' older than 2 days, skip')
continue
from_addr = getattr(mail, 'mailfrom', '?')
subject = getattr(mail, 'subject', '?')
body_head = (getattr(mail, 'body', '') or '')[:50].replace('\n', ' ')
debug('mail[' + idx + '] date=' + str(mail_date_str) + ' from=' + str(from_addr) + ' subj=' + str(subject) + ' body=' + body_head)
if from_addr != '95555@message.cmbchina.com':
debug('mail[' + idx + '] EXIT: wrong sender')
continue
if not (getattr(mail, 'body', '') or '').startswith('动账业务通知'):
debug('mail[' + idx + '] EXIT: not 动账业务通知')
continue
data = self.get_transfer_data(mail)
if data is None:
debug('mail[' + idx + '] EXIT: get_transfer_data returned None')
continue
code = str(data.code)
amount = data.amount
debug('mail[' + idx + '] code=' + code + ' amount=' + str(amount) + ' looking_for=' + tcode)
if code == tcode:
matched = data
debug('mail[' + idx + '] *** MATCHED tcode=' + tcode + ' amount=' + str(amount) + ' ***')
break
debug('mail[' + idx + '] EXIT: code mismatch ' + code + ' != ' + tcode)
except Exception as e:
debug('mail[' + str(i) + '] ERROR: ' + str(e))
debug('check_transfer: scanned ' + str(scanned) + ' mails, matched=' + str(matched is not None))
if matched is None:
return ('尚未到账', '扫描了 ' + str(scanned) + ' 封邮件,暂未收到转账码 ' + tcode + ' 的到账通知')
# 执行入账
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('transfercode', {'tcode': tcode, 'status': '0'})
if len(recs) < 1:
return ('已处理', '转账码 ' + tcode + ' 已处理或不存在')
tc = recs[0]
diff = abs(float(matched.amount) - float(tc.amount))
if diff > 0.01:
await sor.U('transfercode', {'id': tc.id, 'status': '9',
'remark': '金额不匹配: 到账' + str(matched.amount) + ' 期望' + str(tc.amount)})
return ('金额不匹配', '到账金额 ' + str(matched.amount) + ' 与充值金额 ' + str(tc.amount) + ' 不一致')
biz_date = await env.get_business_date(sor)
await sor.U('transfercode', {'id': tc.id, 'status': '1'})
recs2 = await sor.R('payment_log', {'id': tc.id, 'payment_status': '0'})
if len(recs2) > 0:
plog = recs2[0]
await env.recharge_accounting(sor,
plog.customerid, 'RECHARGE', plog.id,
biz_date, plog.amount_total, plog.pay_feerate)
await sor.U('payment_log', {
'id': plog.id,
'payment_status': '1',
'payed_timestamp': timestampstr()
})
debug('check_transfer: ACCOUNTING DONE for ' + tcode + ' amount=' + str(matched.amount))
return ('充值成功', '转账码 ' + tcode + ' 已确认到账 ' + str(matched.amount) + '')
async def query(self, out_trade_no: str) -> Dict[str, Any]:
pass
async def refund(self, *, out_trade_no: str, refund_amount: str, out_request_no: str) -> Dict[str, Any]:
pass
async def handle_notify(self, request) -> Dict[str, Any]:
pass