# -*- coding: utf-8 -*- """客户端 api 第 4 批接口冒烟:图形/短信验证码、登录、收银台、支付回调。 用法:起 Java 18080 后执行 python tools/test_api_batch4.py 环境变量:YOSHOP_API_BASE(默认 http://127.0.0.1:18080) 说明: - 图形验证码的答案从 Redis 读取(模拟用户看图输入),验证码接口本身走真实链路; - 短信通道未接入,验证码只落 Redis,脚本同样从 Redis 取值完成登录; - 会真实下单/支付/回调,finally 中删除测试订单、交易记录、充值单、余额流水与测试用户, 并恢复用户余额 / 总支付金额 / 商品销量。 """ import hashlib import json import os import re import socket import sys import pymysql import requests from _root import ROOT from redis_probe import cmd BASE = os.environ.get("YOSHOP_API_BASE", "http://127.0.0.1:18080") TOKEN = "apitest-batch4" USER_ID = 10001 # 登录用测试手机号(脚本会创建并在 finally 删除) TEST_MOBILE = "13900001111" # 支付链路测试商品(下单减库存、单规格由 make_order 固定为 goods_sku_id) GOODS_ID = 10009 SKU_ID = "10009" YML = str(ROOT / "yoshop-java/src/main/resources/application.yml") TEXT = open(YML, encoding="utf-8").read() DB_PWD = re.findall(r"password:\s*(.+)", TEXT)[0].strip().strip("\"'") REDIS_PWD = re.findall(r"password:\s*(.+)", TEXT)[1].strip().strip("\"'") S = requests.Session() S.trust_env = False passed = 0 failed = 0 created_orders = [] created_trades = [] created_recharge_orders = [] created_payments = [] backup = {} def check(name, cond, detail=""): global passed, failed if cond: passed += 1 print(f" PASS {name}") else: failed += 1 print(f" FAIL {name} {detail}") def call(method, path, params=None, body=None, token=TOKEN): headers = {"platform": "MP-WEIXIN"} if token: headers["Access-Token"] = token if method == "GET": resp = S.get(BASE + path, params=params or {}, headers=headers, timeout=60) else: resp = S.post(BASE + path, params=params or {}, json=body, headers=headers, timeout=60) try: return resp.json() except Exception: return {"status": resp.status_code, "raw": resp.text[:300]} def get(path, params=None, token=TOKEN): return call("GET", path, params=params, token=token) def post(path, body=None, params=None, token=TOKEN): return call("POST", path, params=params, body=body, token=token) def raw_post(path, text=None, data=None, headers=None): resp = S.post(BASE + path, data=text if text is not None else data, headers=headers or {}, timeout=60) return resp.text def snake_keys(obj, prefix="$"): found = [] if isinstance(obj, dict): for k, v in obj.items(): if isinstance(k, str) and re.fullmatch(r"[a-z0-9]+(_[a-z0-9]+)+", k): found.append(f"{prefix}.{k}") found.extend(snake_keys(v, f"{prefix}.{k}")) elif isinstance(obj, list): for i, v in enumerate(obj[:3]): found.extend(snake_keys(v, f"{prefix}[{i}]")) return found def db(sql): conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=DB_PWD, database="shop", charset="utf8mb4", autocommit=True) try: cur = conn.cursor() cur.execute(sql) return cur.fetchall() finally: conn.close() def db_exec(sql): conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=DB_PWD, database="shop", charset="utf8mb4", autocommit=True) try: cur = conn.cursor() cur.execute(sql) return cur.lastrowid finally: conn.close() def redis_cmd(*args): sock = socket.create_connection(("192.168.1.177", 6379), timeout=5) try: cmd(sock, "AUTH", REDIS_PWD) return cmd(sock, *args) finally: sock.close() def redis_get(key): return redis_cmd("GET", key) def redis_del(key): return redis_cmd("DEL", key) def field(obj, name, default=None): return obj.get(name, default) if isinstance(obj, dict) else default def new_captcha(): """取一张新图形验证码,返回 (接口数据, 明文验证码)""" data = get("/api/captcha/image", token=None).get("data") or {} raw = redis_get("yoshop:api:captcha:" + str(data.get("key"))) code = json.loads(raw)["code"] if raw else None return data, code def send_sms(mobile): """完整走一遍「图形验证码 → 发送短信」,返回 (响应, 短信验证码, 所用图形验证码)""" data, code = new_captcha() resp = post("/api/captcha/sendSmsCaptcha", {"mobile": mobile, "captchaCode": code, "captchaKey": data.get("key")}, token=None) raw = redis_get("yoshop:api:sms:" + mobile) sms = json.loads(raw)["code"] if raw else None return resp, sms, data def make_order(order_status=10, pay_status=10, order_no=None): order_no = order_no or ("TEST" + str(os.getpid()) + str(len(created_orders))) order_id = db_exec( "INSERT INTO yoshop_order (order_no, order_type, total_price, order_price, coupon_id, coupon_money, " "points_money, points_num, pay_price, update_price, buyer_remark, pay_method, pay_status, trade_id, " "delivery_type, express_price, delivery_status, receipt_status, order_status, points_bonus, " "is_settled, is_comment, order_source, order_source_id, order_source_data, platform, user_id, " "is_delete, store_id, create_time, update_time) VALUES " f"('{order_no}', 10, 80.00, 80.00, 0, 0.00, 0.00, 0, 83.00, 0.00, '', '', {pay_status}, 0, " f"10, 3.00, 10, 10, {order_status}, 0, 0, 0, 10, 0, '[]', " f"'H5', {USER_ID}, 0, 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())") created_orders.append(order_id) db_exec( "INSERT INTO yoshop_order_goods (goods_id, goods_type, goods_name, image_id, deduct_stock_type, " "spec_type, goods_sku_id, goods_props, content, goods_no, goods_price, line_price, goods_weight, " "is_user_grade, grade_ratio, grade_goods_price, grade_total_money, coupon_money, points_money, " "points_num, points_bonus, total_num, total_price, total_pay_price, delivery_status, delivery_num, " "is_comment, order_id, user_id, goods_source_id, store_id, create_time) VALUES " f"({GOODS_ID}, 10, '正宗荔浦芋头', 0, 10, 20, '{SKU_ID}', '', '', '', 80.00, 100.00, 4.5, " f"0, 0, 0.00, 0.00, 0.00, 0.00, 0, 0, 1, 80.00, 80.00, 10, 0, 0, {order_id}, {USER_ID}, 0, " "10001, UNIX_TIMESTAMP())") return order_id, order_no def make_trade(order_id, order_no, method="wechat", order_type=10, user_id=USER_ID): out_trade_no = "NT" + str(os.getpid()) + str(len(created_trades)) + str(int(order_id)) trade_id = db_exec( "INSERT INTO yoshop_payment_trade (out_trade_no, client, pay_method, order_type, order_id, order_no, " "user_id, trade_no, prepay_id, trade_state, store_id, create_time, update_time) VALUES " f"('{out_trade_no}', 'MP-WEIXIN', '{method}', {order_type}, {order_id}, '{order_no}', {user_id}, " f"'', '', 10, 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())") created_trades.append(trade_id) return trade_id, out_trade_no def cleanup(): try: ids = ",".join(str(i) for i in created_orders) or "0" tids = ",".join(str(i) for i in created_trades) or "0" rids = ",".join(str(i) for i in created_recharge_orders) or "0" db_exec(f"DELETE FROM yoshop_payment_trade WHERE trade_id IN ({tids})") db_exec(f"DELETE FROM yoshop_order_goods WHERE order_id IN ({ids})") db_exec(f"DELETE FROM yoshop_order WHERE order_id IN ({ids})") db_exec(f"DELETE FROM yoshop_recharge_order WHERE order_id IN ({rids})") pids = ",".join(str(i) for i in created_payments) or "0" db_exec(f"DELETE FROM yoshop_payment WHERE payment_id IN ({pids})") db_exec(f"DELETE FROM yoshop_user_balance_log WHERE user_id={USER_ID} AND " f"(`describe` LIKE '用户消费:TEST%' OR `describe` LIKE '用户充值:TEST%')") if backup: db_exec(f"UPDATE yoshop_user SET balance={backup['balance']}, pay_money={backup['pay_money']}, " f"points={backup['points']} WHERE user_id={USER_ID}") db_exec(f"UPDATE yoshop_goods SET sales_actual={backup['sales_actual']} WHERE goods_id={GOODS_ID}") db_exec(f"DELETE FROM yoshop_user_oauth WHERE user_id IN " f"(SELECT user_id FROM yoshop_user WHERE mobile='{TEST_MOBILE}')") db_exec(f"DELETE FROM yoshop_user WHERE mobile='{TEST_MOBILE}'") for key in ["yoshop:api:sms:" + TEST_MOBILE, "yoshop:api:sms:send:" + TEST_MOBILE, "yoshop:api:token:" + TOKEN]: redis_del(key) print(" CLEAN 测试数据已清理") except Exception as e: # noqa: BLE001 print(" CLEAN 失败:", e) def main(): print("== 客户端 api 第 4 批(验证码 / 登录 / 收银台 / 支付回调) ==") redis_cmd("SET", "yoshop:api:token:" + TOKEN, str(USER_ID)) redis_cmd("EXPIRE", "yoshop:api:token:" + TOKEN, "3600") row = db(f"SELECT balance, pay_money, points FROM yoshop_user WHERE user_id={USER_ID}")[0] backup.update(balance=row[0], pay_money=row[1], points=row[2], sales_actual=db(f"SELECT sales_actual FROM yoshop_goods WHERE goods_id={GOODS_ID}")[0][0]) for key in ["yoshop:api:sms:" + TEST_MOBILE, "yoshop:api:sms:send:" + TEST_MOBILE]: redis_del(key) # ---------------------------------------------------------------- 图形验证码 print("\n[1] 图形验证码") r = get("/api/captcha/image", token=None) data = field(r, "data") or {} check("captcha/image 成功", r.get("status") == 200, r) check("出参仅 base64/key/md5", sorted(data.keys()) == ["base64", "key", "md5"], list(data.keys())) base64_text = str(data.get("base64")) check("base64 为 png data URI", base64_text.startswith("data:image/png;base64,") and len(base64_text) > 1000, base64_text[:40]) check("key 为 bcrypt 摘要", str(data.get("key", "")).startswith("$2"), str(data.get("key"))[:10]) cache = redis_get("yoshop:api:captcha:" + str(data.get("key"))) cached = json.loads(cache) if cache else {} check("验证码写入 Redis", cached.get("code") and cached.get("times") == 5, cache) check("md5 为验证码摘要", data.get("md5") == hashlib.md5(str(cached.get("code")).encode()).hexdigest(), data.get("md5")) ttl = redis_cmd("TTL", "yoshop:api:captcha:" + str(data.get("key"))) check("验证码 TTL 为 1800 秒内", isinstance(ttl, int) and 0 < ttl <= 1800, ttl) # ---------------------------------------------------------------- 发送短信验证码 print("\n[2] 发送短信验证码") r = post("/api/captcha/sendSmsCaptcha", {"captchaCode": "abc", "captchaKey": "x"}, token=None) check("缺手机号报错", r.get("status") == 500 and "手机号不能为空" in (r.get("message") or ""), r) r = post("/api/captcha/sendSmsCaptcha", {"mobile": TEST_MOBILE}, token=None) check("缺图形验证码报错", r.get("status") == 500 and "图形验证码code不能为空" in (r.get("message") or ""), r) r = post("/api/captcha/sendSmsCaptcha", {"mobile": TEST_MOBILE, "captchaCode": "wrong", "captchaKey": data.get("key")}, token=None) check("图形验证码错误报错", r.get("status") == 500 and "图形验证码不正确" in (r.get("message") or ""), r) r = post("/api/captcha/sendSmsCaptcha", {"mobile": TEST_MOBILE, "captchaCode": "x"}, token=None) check("图形验证码 key 为空报错", r.get("status") == 500 and "图形验证码key不能为空" in (r.get("message") or ""), r) r, sms_code, used_captcha = send_sms(TEST_MOBILE) check("短信发送成功", r.get("status") == 200 and "发送成功" in (r.get("message") or ""), r) check("发送成功 data 为空数组", r.get("data") == [], r.get("data")) check("短信验证码为 6 位数字", bool(sms_code) and re.fullmatch(r"\d{6}", sms_code), sms_code) r = post("/api/captcha/sendSmsCaptcha", {"mobile": TEST_MOBILE, "captchaCode": "x", "captchaKey": used_captcha.get("key")}, token=None) check("图形验证码用后即焚", r.get("status") == 500 and "图形验证码不存在" in (r.get("message") or ""), r) # 每日发送次数限制:首次已用 1 次,再发 9 次后应为 0 次 last = None for _ in range(9): last, _, _ = send_sms(TEST_MOBILE) check("第 10 次发送仍成功", last.get("status") == 200, last) last, _, _ = send_sms(TEST_MOBILE) check("超出每日发送次数被拒", last.get("status") == 500 and "已超出今日最大发送次数限制" in (last.get("message") or ""), last) left = redis_get("yoshop:api:sms:send:" + TEST_MOBILE) check("发送次数计为 0", str(left) == "0", left) # 重置发送次数窗口,避免影响后续登录用例 redis_del("yoshop:api:sms:send:" + TEST_MOBILE) # ---------------------------------------------------------------- 登录 print("\n[3] 手机号 + 短信验证码登录") r = post("/api/passport/login", {"mobile": TEST_MOBILE}, token=None) check("缺短信验证码报错", r.get("status") == 500 and "短信验证码不能为空" in (r.get("message") or ""), r) r = post("/api/passport/login", {"smsCode": "123456"}, token=None) check("缺手机号报错", r.get("status") == 500 and "手机号不能为空" in (r.get("message") or ""), r) redis_del("yoshop:api:sms:" + TEST_MOBILE) r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": "123456"}, token=None) check("验证码不存在报错", r.get("status") == 500 and "短信验证码不存在" in (r.get("message") or ""), r) r = post("/api/passport/login", {"mobile": "12345", "smsCode": "123456"}, token=None) check("手机号格式校验", r.get("status") == 500 and "手机号格式不正确" in (r.get("message") or ""), r) _, sms_code, _ = send_sms(TEST_MOBILE) r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": "000000"}, token=None) check("验证码错误报错", r.get("status") == 500 and "短信验证码不正确" in (r.get("message") or ""), r) check("验证码错误后次数减一", json.loads(redis_get("yoshop:api:sms:" + TEST_MOBILE)).get("times") == 4, redis_get("yoshop:api:sms:" + TEST_MOBILE)) r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": sms_code}, token=None) login_data = field(r, "data") or {} check("登录成功", r.get("status") == 200 and r.get("message") == "登录成功", r) check("返回 userId 与 token", isinstance(login_data.get("userId"), int) and re.fullmatch(r"[0-9a-f]{32}", str(login_data.get("token"))) is not None, login_data) check("登录后验证码用后即焚", redis_get("yoshop:api:sms:" + TEST_MOBILE) is None) user_id = login_data.get("userId") check("新用户写入库", bool(user_id) and db(f"SELECT COUNT(*) FROM yoshop_user WHERE user_id={user_id}")[0][0] == 1) nick = db(f"SELECT nick_name, platform FROM yoshop_user WHERE user_id={user_id}")[0] if user_id else ("", "") check("默认昵称为打码手机号", nick[0] == "139****1111", nick) check("注册来源取 platform 头", nick[1] == "MP-WEIXIN", nick) login_token = str(login_data.get("token")) r = get("/api/user/info", token=login_token) info = field(field(r, "data"), "userInfo") or {} check("登录态可用", r.get("status") == 200 and info.get("userId") == user_id, r) check("用户信息手机号打码", info.get("mobile") == "139****1111", info.get("mobile")) _, sms_code, _ = send_sms(TEST_MOBILE) r = post("/api/passport/login", {"mobile": TEST_MOBILE, "smsCode": sms_code}, token=None) check("重复登录复用同一账号", field(r, "data", {}).get("userId") == user_id, r) # ---------------------------------------------------------------- 微信小程序登录 print("\n[4] 微信小程序登录") r = post("/api/passport/loginMpWx", {"partyData": {"oauth": "MP-WEIXIN", "userInfo": {}}}, token=None) check("缺 code 报错", r.get("status") == 500 and "code 不能为空" in (r.get("message") or ""), r) r = post("/api/passport/loginMpWx", {"partyData": {"oauth": "MP-WEIXIN", "code": "fake-code", "userInfo": {}}}, token=None) check("未配置小程序参数时报错", r.get("status") == 500 and "AppID和AppSecret" in (r.get("message") or ""), r) r = post("/api/passport/loginMpWxMobile", {"code": "fake-code", "encryptedData": "x", "iv": "y"}, token=None) check("授权手机号登录同样受小程序配置限制", r.get("status") == 500 and "AppID和AppSecret" in (r.get("message") or ""), r) r = post("/api/passport/isPersonalMpweixin", {"code": "fake-code"}, token=None) check("isPersonalMpweixin 关闭时直接返回 false", r.get("status") == 200 and field(r, "data", {}).get("isPersonalMpweixin") is False, r) check("微信登录接口零 snake key", not snake_keys(r), snake_keys(r)) # ---------------------------------------------------------------- 收银台 print("\n[5] 收银台") r = get("/api/cashier/orderInfo", {"orderId": 1, "client": "MP-WEIXIN"}, token=None) check("未登录访问收银台 401", r.get("status") == 401, r) order_id, order_no = make_order() cancelled_id, _ = make_order(order_status=20) r = get("/api/cashier/orderInfo", {"orderId": 999999, "client": "MP-WEIXIN"}) check("订单不存在报错", r.get("status") == 500 and "订单不存在" in (r.get("message") or ""), r) r = get("/api/cashier/orderInfo", {"orderId": cancelled_id, "client": "MP-WEIXIN"}) check("已取消订单不可支付", r.get("status") == 500 and "不允许支付" in (r.get("message") or ""), r) r = get("/api/cashier/orderInfo", {"orderId": order_id, "client": "MP-WEIXIN"}) info = field(r, "data") or {} order_part = info.get("order") or {} check("收银台返回订单/用户/支付方式", r.get("status") == 200 and sorted(info.keys()) == ["order", "paymentMethods", "personal"], list(info.keys())) check("订单金额两位小数字符串", order_part.get("payPrice") == "83.00", order_part) check("返回超时信息", order_part.get("showExpiration") is True and re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(order_part.get("expirationTime"))) is not None, order_part) check("订单字段齐全", {"orderId", "orderNo", "payStatus", "orderStatus", "createTime"} <= set(order_part.keys()), list(order_part.keys())) methods = [m.get("method") for m in (info.get("paymentMethods") or [])] check("支付方式含余额支付", "balance" in methods, methods) check("收银台零 snake key", not snake_keys(info), snake_keys(info)) r = post("/api/cashier/orderPay", {"orderId": order_id, "client": "MP-WEIXIN"}) check("缺支付方式报错", r.get("status") == 500 and "请选择支付方式" in (r.get("message") or ""), r) r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "unknown", "client": "MP-WEIXIN"}) check("支付方式不可用报错", r.get("status") == 500 and "支付方式不可用" in (r.get("message") or ""), r) db_exec(f"UPDATE yoshop_user SET balance=0 WHERE user_id={USER_ID}") r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "balance", "client": "MP-WEIXIN"}) check("余额不足报错", r.get("status") == 500 and "余额不足" in (r.get("message") or ""), r) wechat_payment_id = db_exec( "INSERT INTO yoshop_payment (client, method, is_must_template, template_id, is_enable, is_default, " "others, store_id, create_time, update_time) VALUES ('MP-WEIXIN', 'wechat', 0, 0, 1, 0, '{}', 10001, " "UNIX_TIMESTAMP(), UNIX_TIMESTAMP())") created_payments.append(wechat_payment_id) r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "wechat", "client": "MP-WEIXIN"}) check("微信支付未接入有明确文案", r.get("status") == 500 and "尚未接入" in (r.get("message") or ""), r) pay_money_before = float(db(f"SELECT pay_money FROM yoshop_user WHERE user_id={USER_ID}")[0][0]) db_exec(f"UPDATE yoshop_user SET balance=500 WHERE user_id={USER_ID}") r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "balance", "client": "MP-WEIXIN"}) check("余额支付成功", r.get("status") == 200 and r.get("message") == "恭喜您,订单已付款成功", r) check("余额支付不返回第三方参数", field(r, "data", {}).get("payment") == [], r.get("data")) row = db(f"SELECT pay_status, pay_method, trade_id, pay_time FROM yoshop_order WHERE order_id={order_id}")[0] check("订单置为已付款", row[0] == 20 and row[1] == "balance" and row[3] > 0, row) balance = float(db(f"SELECT balance FROM yoshop_user WHERE user_id={USER_ID}")[0][0]) check("余额扣减订单金额", abs(balance - 417.0) < 0.001, balance) pay_money = float(db(f"SELECT pay_money FROM yoshop_user WHERE user_id={USER_ID}")[0][0]) check("累加用户总支付金额", abs(pay_money - pay_money_before - 83.0) < 0.001, pay_money) logs = db(f"SELECT scene, money, `describe` FROM yoshop_user_balance_log WHERE user_id={USER_ID} " f"AND `describe`='用户消费:{order_no}'") check("写余额消费明细", len(logs) == 1 and logs[0][0] == 20 and float(logs[0][1]) == -83.0, logs) sales = db(f"SELECT sales_actual FROM yoshop_goods WHERE goods_id={GOODS_ID}")[0][0] check("累计商品销量", sales == backup["sales_actual"] + 1, (sales, backup["sales_actual"])) r = post("/api/cashier/orderPay", {"orderId": order_id, "method": "balance", "client": "MP-WEIXIN"}) check("已支付订单不可重复支付", r.get("status") == 500 and "已支付" in (r.get("message") or ""), r) r = get("/api/cashier/tradeQuery", {"method": "wechat", "client": "MP-WEIXIN"}) check("交易查询缺单号报错", r.get("status") == 500 and "商户订单号不能为空" in (r.get("message") or ""), r) r = get("/api/cashier/tradeQuery", {"outTradeNo": "X1", "method": "wechat", "client": "MP-WEIXIN"}) check("交易查询失败仍返回成功响应", r.get("status") == 200 and field(r, "data", {}).get("isPay") is False, r) check("交易查询命中未接入文案", "尚未接入" in (r.get("message") or ""), r.get("message")) r = get("/api/cashier/tradeQuery", {"outTradeNo": "X1", "method": "balance", "client": "MP-WEIXIN"}) check("非第三方交易查询返回未支付", r.get("status") == 200 and field(r, "data", {}).get("isPay") is False and "未支付" in (r.get("message") or ""), r) # ---------------------------------------------------------------- 支付回调 print("\n[6] 支付回调") text = raw_post("/api/notify/wechatV2", text="NOT-EXIST", headers={"Content-Type": "text/xml"}) check("微信 V2 未知单号返回 FAIL", text == "FAIL", text) text = raw_post("/api/notify/wechatV2", text="not-xml", headers={"Content-Type": "text/xml"}) check("微信 V2 报文异常返回 FAIL", text == "FAIL", text) text = raw_post("/api/notify/wechatV3", text="{}", headers={"Content-Type": "application/json"}) check("微信 V3 未接入返回失败 JSON", '"code": "FAIL"' in text, text) order2, order_no2 = make_order() _, out2 = make_trade(order2, order_no2, method="wechat") xml = (f"" f"" f"") text = raw_post("/api/notify/wechatV2", text=xml, headers={"Content-Type": "text/xml"}) check("微信 V2 回调返回 SUCCESS", "SUCCESS" in text, text) row = db(f"SELECT pay_status, pay_method FROM yoshop_order WHERE order_id={order2}")[0] check("微信 V2 回调置订单已付款", row[0] == 20 and row[1] == "wechat", row) trade = db(f"SELECT trade_state, trade_no FROM yoshop_payment_trade WHERE out_trade_no='{out2}'")[0] check("交易记录置为已支付", trade[0] == 20 and trade[1] == "WX" + out2, trade) text = raw_post("/api/notify/wechatV2", text=xml, headers={"Content-Type": "text/xml"}) check("重复回调幂等返回 SUCCESS", "SUCCESS" in text, text) order3, order_no3 = make_order() _, out3 = make_trade(order3, order_no3, method="alipay") text = raw_post("/api/notify/alipay", data={"out_trade_no": out3, "trade_no": "ALI" + out3}) check("支付宝回调返回 success", text == "success", text) row = db(f"SELECT pay_status, pay_method FROM yoshop_order WHERE order_id={order3}")[0] check("支付宝回调置订单已付款", row[0] == 20 and row[1] == "alipay", row) text = raw_post("/api/notify/alipay", data={"out_trade_no": "NOT-EXIST"}) check("支付宝未知单号返回 fail", text == "fail", text) # 充值订单回调 recharge_no = "TESTR" + str(os.getpid()) recharge_id = db_exec( "INSERT INTO yoshop_recharge_order (order_no, user_id, recharge_type, plan_id, pay_price, gift_money, " "actual_money, pay_method, pay_status, pay_time, trade_id, platform, store_id, create_time, update_time) " f"VALUES ('{recharge_no}', {USER_ID}, 10, 0, 100.00, 10.00, 110.00, '', 10, 0, 0, " f"'MP-WEIXIN', 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())") created_recharge_orders.append(recharge_id) _, out4 = make_trade(recharge_id, recharge_no, method="alipay", order_type=100) balance_before = float(db(f"SELECT balance FROM yoshop_user WHERE user_id={USER_ID}")[0][0]) text = raw_post("/api/notify/alipay", data={"out_trade_no": out4, "trade_no": "ALI" + out4}) check("充值订单回调返回 success", text == "success", text) row = db(f"SELECT pay_status FROM yoshop_recharge_order WHERE order_id={recharge_id}")[0] check("充值订单置为已支付", row[0] == 20, row) balance = float(db(f"SELECT balance FROM yoshop_user WHERE user_id={USER_ID}")[0][0]) check("充值金额到账", abs(balance - balance_before - 110.0) < 0.001, (balance, balance_before)) logs = db(f"SELECT COUNT(*) FROM yoshop_user_balance_log WHERE user_id={USER_ID} " f"AND `describe`='用户充值:{recharge_no}'") check("写充值明细", logs[0][0] == 1, logs) print(f"\n结果:PASS {passed} / FAIL {failed}") return 1 if failed else 0 if __name__ == "__main__": try: code = main() finally: cleanup() sys.exit(code)