Ver código fonte

新增客户端 api 模块第四批接口(12 个),客户端 74 个接口全部实现

- 登录:passport/login(手机号+短信验证码,自动注册)、passport/loginMpWx(小程序一键登录,
  未注册且强制绑定手机号时返回 isBindMobile)、passport/loginMpWxMobile(jscode2session + AES 解密)、
  passport/isPersonalMpweixin(设置关闭时直接 false)
- 验证码:captcha/image(hutool 图形验证码,key 为 BCrypt 摘要,Redis 存 code/times)、
  captcha/sendSmsCaptcha(先校验图形验证码,再按手机号每日限 10 次并写入 6 位短信验证码)
- 收银台:cashier/orderInfo(订单+用户+该客户端支付方式,含订单超时)、cashier/orderPay、
  cashier/tradeQuery;余额支付全链路可用(扣余额、写消费明细、置订单已付款、累计销量)
- 支付回调:notify/wechatV2(XML)、notify/wechatV3(JSON)、notify/alipay(表单),
  按交易记录订单类型分发到商城订单结算与余额充值结算;验签为骨架,未配置商户时跳过并告警
- 新增 ApiPassportService、ApiCaptchaService、ApiCashierService、ApiPaySuccessService、
  ApiPaymentService、ApiNotifyService 与四个控制器;新增实体 UserOauth
- BizException 支持附加 data(isBindMobile / isBack),全局异常按 PHP array_merge 语义合并输出
- ApiPaymentService 收敛支付方式过滤与交易记录写入,ApiRechargeService 改为复用
- 新增验证脚本 tools/test_api_batch4.py(77 项断言全过,跑完自动还原数据)
- 新增文档 接口实现进度-第13批-api.md,联调说明更新客户端 api 章节为全部实现
tanlie 1 dia atrás
pai
commit
b9abd0b0a5
19 arquivos alterados com 2363 adições e 33 exclusões
  1. 484 0
      tools/test_api_batch4.py
  2. 39 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiCaptchaController.java
  3. 64 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiCashierController.java
  4. 46 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiNotifyController.java
  5. 59 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiPassportController.java
  6. 175 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiCaptchaService.java
  7. 213 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiCashierService.java
  8. 199 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiNotifyService.java
  9. 502 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiPassportService.java
  10. 199 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiPaySuccessService.java
  11. 147 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiPaymentService.java
  12. 2 20
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiRechargeService.java
  13. 14 3
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiSmsService.java
  14. 39 0
      yoshop-java/src/main/java/cn/yoshop/common/entity/UserOauth.java
  15. 18 0
      yoshop-java/src/main/java/cn/yoshop/common/exception/BizException.java
  16. 16 2
      yoshop-java/src/main/java/cn/yoshop/common/exception/GlobalExceptionHandler.java
  17. 3 1
      yoshop-java/src/main/java/cn/yoshop/common/mapper/UserOauthMapper.java
  18. 127 0
      接口实现进度-第13批-api.md
  19. 17 7
      联调说明.md

+ 484 - 0
tools/test_api_batch4.py

@@ -0,0 +1,484 @@
+# -*- 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="<xml><out_trade_no>NOT-EXIST</out_trade_no></xml>",
+                    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"<xml><out_trade_no><![CDATA[{out2}]]></out_trade_no>"
+           f"<transaction_id><![CDATA[WX{out2}]]></transaction_id>"
+           f"<return_code><![CDATA[SUCCESS]]></return_code></xml>")
+    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)

+ 39 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiCaptchaController.java

@@ -0,0 +1,39 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiCaptchaService;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.util.Params;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 客户端验证码,对齐 PHP app/api/controller/Captcha。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiCaptchaController {
+
+    private final ApiCaptchaService apiCaptchaService;
+
+    /** /api/captcha/image 图形验证码(base64 / key / md5) */
+    @RequestMapping(value = "/captcha/image", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> image() {
+        return R.ok(apiCaptchaService.createImage());
+    }
+
+    /** /api/captcha/sendSmsCaptcha 发送短信验证码(表单:mobile / captchaCode / captchaKey) */
+    @RequestMapping(value = "/captcha/sendSmsCaptcha", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> sendSmsCaptcha(@RequestBody(required = false) Map<String, Object> body,
+                                  HttpServletRequest request) {
+        Map<String, Object> params = Params.merge(body, request);
+        return R.okMsg(apiCaptchaService.sendSmsCaptcha(params));
+    }
+}

+ 64 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiCashierController.java

@@ -0,0 +1,64 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiCashierService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.util.Params;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 客户端收银台,对齐 PHP app/api/controller/Cashier。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiCashierController {
+
+    private final ApiCashierService apiCashierService;
+
+    /** /api/cashier/orderInfo 待支付订单信息(orderId / client) */
+    @RequestMapping(value = "/cashier/orderInfo", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> orderInfo(@RequestBody(required = false) Map<String, Object> body,
+                                            HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        Map<String, Object> params = Params.merge(body, request);
+        return R.ok(apiCashierService.orderInfo(user,
+                Params.intVal(params, "orderId", 0),
+                Params.str(params, "client", "")));
+    }
+
+    /** /api/cashier/orderPay 确认支付(orderId / method / client / extra) */
+    @RequestMapping(value = "/cashier/orderPay", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> orderPay(@RequestBody(required = false) Map<String, Object> body,
+                                           HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        Map<String, Object> params = Params.merge(body, request);
+        ApiCashierService.PayResult result = apiCashierService.orderPay(user,
+                Params.intVal(params, "orderId", 0),
+                Params.str(params, "method", ""),
+                Params.str(params, "client", ""));
+        return R.ok(result.data(), result.message());
+    }
+
+    /** /api/cashier/tradeQuery 交易查询(outTradeNo / method / client) */
+    @RequestMapping(value = "/cashier/tradeQuery", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> tradeQuery(@RequestBody(required = false) Map<String, Object> body,
+                                             HttpServletRequest request) {
+        ApiContext.requireUser(request);
+        Map<String, Object> params = Params.merge(body, request);
+        ApiCashierService.TradeQueryResult result = apiCashierService.tradeQuery(
+                Params.str(params, "outTradeNo", ""),
+                Params.str(params, "method", ""),
+                Params.str(params, "client", ""));
+        return R.ok(Map.of("isPay", result.isPay()), result.message());
+    }
+}

+ 46 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiNotifyController.java

@@ -0,0 +1,46 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiNotifyService;
+import cn.yoshop.common.util.Params;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 第三方支付异步通知,对齐 PHP app/api/controller/Notify。
+ *
+ * <p>出参不是统一响应体:微信 / 支付宝要求按各自约定返回纯文本或 JSON,
+ * 因此这里直接返回字符串(验签失败也要按渠道约定返回,否则第三分会持续重试)。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiNotifyController {
+
+    private final ApiNotifyService apiNotifyService;
+
+    /** /api/notify/wechatV2 微信支付 V2 异步通知(XML 报文) */
+    @PostMapping(value = "/notify/wechatV2", produces = MediaType.TEXT_PLAIN_VALUE)
+    public String wechatV2(@RequestBody(required = false) String body) {
+        return apiNotifyService.wechatV2(body);
+    }
+
+    /** /api/notify/wechatV3 微信支付 V3 异步通知(JSON 报文,返回 JSON) */
+    @PostMapping(value = "/notify/wechatV3", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String wechatV3(@RequestBody(required = false) String body,
+                           @RequestHeader(value = "Wechatpay-Serial", required = false) String serial) {
+        return apiNotifyService.wechatV3(body, serial);
+    }
+
+    /** /api/notify/alipay 支付宝异步通知(表单参数) */
+    @PostMapping(value = "/notify/alipay", produces = MediaType.TEXT_PLAIN_VALUE)
+    public String alipay(HttpServletRequest request) {
+        return apiNotifyService.alipay(Params.merge(null, request));
+    }
+}

+ 59 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiPassportController.java

@@ -0,0 +1,59 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiPassportService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.util.Params;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 客户端用户认证,对齐 PHP app/api/controller/Passport。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiPassportController {
+
+    private final ApiPassportService apiPassportService;
+
+    /** /api/passport/login 手机号 + 短信验证码登录(自动注册) */
+    @RequestMapping(value = "/passport/login", method = {RequestMethod.POST})
+    public R<Map<String, Object>> login(@RequestBody(required = false) Map<String, Object> body,
+                                        HttpServletRequest request) {
+        Map<String, Object> form = Params.merge(body, request);
+        return R.ok(apiPassportService.login(form, ApiContext.platform(request)), "登录成功");
+    }
+
+    /** /api/passport/loginMpWx 微信小程序一键登录 */
+    @RequestMapping(value = "/passport/loginMpWx", method = {RequestMethod.POST})
+    public R<Map<String, Object>> loginMpWx(@RequestBody(required = false) Map<String, Object> body,
+                                            HttpServletRequest request) {
+        Map<String, Object> form = Params.merge(body, request);
+        return R.ok(apiPassportService.loginMpWx(form, ApiContext.platform(request)), "登录成功");
+    }
+
+    /** /api/passport/loginMpWxMobile 微信小程序授权手机号登录 */
+    @RequestMapping(value = "/passport/loginMpWxMobile", method = {RequestMethod.POST})
+    public R<Map<String, Object>> loginMpWxMobile(@RequestBody(required = false) Map<String, Object> body,
+                                                  HttpServletRequest request) {
+        Map<String, Object> form = Params.merge(body, request);
+        return R.ok(apiPassportService.loginMpWxMobile(form, ApiContext.platform(request)), "登录成功");
+    }
+
+    /** /api/passport/isPersonalMpweixin 是否需要填写昵称头像(前端 POST 提交 code) */
+    @RequestMapping(value = "/passport/isPersonalMpweixin", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> isPersonalMpweixin(@RequestBody(required = false) Map<String, Object> body,
+                                                     HttpServletRequest request) {
+        Map<String, Object> params = Params.merge(body, request);
+        boolean result = apiPassportService.isPersonalMpweixin(Params.str(params, "code", ""));
+        return R.ok(Map.of("isPersonalMpweixin", result));
+    }
+}

+ 175 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiCaptchaService.java

@@ -0,0 +1,175 @@
+package cn.yoshop.api.service;
+
+import cn.hutool.captcha.CaptchaUtil;
+import cn.hutool.captcha.LineCaptcha;
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.util.PhpStr;
+import cn.yoshop.utils.RedisUtils;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.bcrypt.BCrypt;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 图形验证码与短信验证码下发,对齐 PHP:
+ * {@code app/api/service/passport/Captcha}、{@code app/api/service/passport/SmsCaptcha}
+ * 与 {@code yiovo/captcha} 的 {@code CaptchaApi}。
+ *
+ * <p>图形验证码:
+ * <ul>
+ *   <li>5 位字符(排除 0/1/l/o 等易混淆字符),PNG 图片以 base64 data URI 返回;</li>
+ *   <li>Redis 键 {@code yoshop:api:captcha:{key}},key 即验证码小写后的 BCrypt 摘要
+ *       (与 PHP 用 {@code password_hash} 当 key 的做法一致)。值为 {@code {"code","times"}},TTL 1800 秒;</li>
+ *   <li>校验失败次数用尽后删除;校验通过立即删除(用后即焚)。</li>
+ * </ul>
+ *
+ * <p>短信验证码:
+ * <ul>
+ *   <li>先校验图形验证码,再按手机号限制每天最多 10 次(Redis {@code yoshop:api:sms:send:{mobile}},TTL 86400 秒);</li>
+ *   <li>验证码 6 位数字,写入 {@link ApiSmsService}(键 {@code yoshop:api:sms:{mobile}});</li>
+ *   <li><b>短信通道尚未接入</b>:验证码只落缓存并写日志,接口仍返回成功文案(与 PHP 当前行为一致)。</li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiCaptchaService {
+
+    /** 图形验证码缓存键前缀 */
+    private static final String IMAGE_KEY_PREFIX = "yoshop:api:captcha:";
+    /** 短信发送次数缓存键前缀 */
+    private static final String SEND_KEY_PREFIX = "yoshop:api:sms:send:";
+
+    /** 有效期(秒),对齐 PHP CaptchaApi::$expire */
+    private static final int EXPIRE_SECONDS = 1800;
+    /** 允许的错误次数,对齐 PHP CaptchaApi::$checkTimes */
+    private static final int CHECK_TIMES = 5;
+
+    /** 每日最大发送次数,对齐 PHP SmsCaptcha::$sendTimes */
+    private static final int SEND_TIMES = 10;
+    /** 发送次数统计窗口(秒),对齐 PHP SmsCaptcha::$safeTime */
+    private static final int SEND_SAFE_SECONDS = 86400;
+
+    private static final int IMAGE_WIDTH = 160;
+    private static final int IMAGE_HEIGHT = 60;
+    private static final int CODE_LENGTH = 5;
+
+    private final RedisUtils redisUtils;
+    private final ApiSmsService smsService;
+
+    // ------------------------------------------------------------------ 图形验证码
+
+    /** 生成图形验证码:{base64, key, md5} */
+    public Map<String, Object> createImage() {
+        LineCaptcha captcha = CaptchaUtil.createLineCaptcha(IMAGE_WIDTH, IMAGE_HEIGHT, CODE_LENGTH, 40);
+        String code = captcha.getCode() == null ? "" : captcha.getCode().toLowerCase();
+        String key = BCrypt.hashpw(code, BCrypt.gensalt(10));
+
+        Map<String, Object> cache = new LinkedHashMap<>();
+        cache.put("code", code);
+        cache.put("times", CHECK_TIMES);
+        redisUtils.setString(IMAGE_KEY_PREFIX + key, PhpStr.jsonEncode(cache), EXPIRE_SECONDS);
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        // 对齐 PHP:base64 带 data URI 前缀,且去掉换行
+        data.put("base64", "data:image/png;base64," + captcha.getImageBase64().replace("\r\n", "").replace("\n", ""));
+        data.put("key", key);
+        data.put("md5", DigestUtil.md5Hex(code));
+        return data;
+    }
+
+    /**
+     * 校验图形验证码,返回是否匹配(不匹配由调用方给文案,对齐 PHP CaptchaApi::check 返回 false)。
+     * 记录不存在 / 次数用尽时直接抛业务异常。
+     */
+    @SuppressWarnings("unchecked")
+    public boolean checkImage(String code, String key) {
+        if (code == null || code.isBlank() || key == null || key.isBlank()) {
+            throw new BizException("很抱歉,图形验证码不正确");
+        }
+        String cacheKey = IMAGE_KEY_PREFIX + key;
+        Map<?, ?> cache = read(cacheKey);
+        if (cache == null || cache.get("code") == null) {
+            throw new BizException("图形验证码不存在,请重新获取");
+        }
+        int times = toInt(cache.get("times"));
+        if (times <= 0) {
+            redisUtils.deleteString(cacheKey);
+            throw new BizException("图形验证码已超出错误次数,请重新获取");
+        }
+        if (!BCrypt.checkpw(code.trim().toLowerCase(), key)) {
+            Map<String, Object> next = new LinkedHashMap<>();
+            next.put("code", String.valueOf(cache.get("code")));
+            next.put("times", times - 1);
+            redisUtils.setString(cacheKey, PhpStr.jsonEncode(next), EXPIRE_SECONDS);
+            return false;
+        }
+        redisUtils.deleteString(cacheKey);
+        return true;
+    }
+
+    // ------------------------------------------------------------------ 短信验证码
+
+    /**
+     * 发送短信验证码:校验图形验证码 → 发送次数限制 → 生成并保存验证码。
+     *
+     * @return 固定文案(对齐 PHP controller 的 "发送成功,请注意查收")
+     */
+    public String sendSmsCaptcha(Map<String, Object> form) {
+        String mobile = cn.yoshop.common.util.Params.str(form, "mobile", "");
+        String captchaCode = cn.yoshop.common.util.Params.str(form, "captchaCode", "");
+        String captchaKey = cn.yoshop.common.util.Params.str(form, "captchaKey", "");
+        if (captchaCode.isBlank()) {
+            throw new BizException("图形验证码code不能为空");
+        }
+        if (captchaKey.isBlank()) {
+            throw new BizException("图形验证码key不能为空");
+        }
+        if (mobile.isBlank()) {
+            throw new BizException("手机号不能为空");
+        }
+        if (!checkImage(captchaCode, captchaKey)) {
+            throw new BizException("很抱歉,图形验证码不正确");
+        }
+        // 发送次数限制(每日 10 次)
+        String recordKey = SEND_KEY_PREFIX + mobile;
+        String raw = redisUtils.getString(recordKey);
+        if (raw == null || raw.isBlank()) {
+            redisUtils.setString(recordKey, String.valueOf(SEND_TIMES - 1), SEND_SAFE_SECONDS);
+        } else {
+            int left = toInt(raw);
+            if (left <= 0) {
+                throw new BizException("很抱歉,已超出今日最大发送次数限制");
+            }
+            redisUtils.setString(recordKey, String.valueOf(left - 1), SEND_SAFE_SECONDS);
+        }
+        // 生成验证码并保存(短信通道未接入时由 ApiSmsService 记录日志)
+        smsService.send(mobile);
+        return "发送成功,请注意查收";
+    }
+
+    // ------------------------------------------------------------------ 工具
+
+    private Map<?, ?> read(String key) {
+        String raw = redisUtils.getString(key);
+        if (raw == null || raw.isBlank()) {
+            return null;
+        }
+        Object decoded = PhpStr.jsonDecodeArray(raw);
+        return decoded instanceof Map<?, ?> map ? map : null;
+    }
+
+    private static int toInt(Object value) {
+        if (value == null) {
+            return 0;
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+}

+ 213 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiCashierService.java

@@ -0,0 +1,213 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.entity.Goods;
+import cn.yoshop.common.entity.GoodsSku;
+import cn.yoshop.common.entity.Order;
+import cn.yoshop.common.entity.OrderGoods;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.GoodsMapper;
+import cn.yoshop.common.mapper.GoodsSkuMapper;
+import cn.yoshop.common.mapper.OrderGoodsMapper;
+import cn.yoshop.common.mapper.OrderMapper;
+import cn.yoshop.common.util.Money;
+import cn.yoshop.common.util.OrderNo;
+import cn.yoshop.common.util.TimeUtil;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 收银台,对齐 PHP app/api/service/Cashier 与 service/cashier/Payment。
+ *
+ * <p>三个接口:
+ * <ul>
+ *   <li>{@link #orderInfo}:待支付订单信息 + 当前用户 + 该客户端可用支付方式(含余额支付);</li>
+ *   <li>{@link #orderPay}:校验订单可支付 → 余额支付当场结算 / 第三方支付走统一下单;</li>
+ *   <li>{@link #tradeQuery}:查询第三方订单是否付款成功。</li>
+ * </ul>
+ *
+ * <p>订单支付超时按交易设置 {@code trade.order.closeHours}(小时)计算,为 0 表示不限制。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiCashierService {
+
+    private static final int ORDER_STATUS_NORMAL = 10;
+    private static final int PAY_STATUS_SUCCESS = 20;
+    private static final int GOODS_OFF_SALE = 20;
+    private static final int DEDUCT_STOCK_ON_PAYMENT = 20;
+
+    private final OrderMapper orderMapper;
+    private final OrderGoodsMapper orderGoodsMapper;
+    private final GoodsMapper goodsMapper;
+    private final GoodsSkuMapper goodsSkuMapper;
+    private final ApiPaymentService apiPaymentService;
+    private final ApiPaySuccessService apiPaySuccessService;
+    private final ApiUserService apiUserService;
+    private final ApiSettingService apiSettingService;
+
+    /** 交易查询结果:是否已支付 + 提示文案(对齐 PHP getError 兜底逻辑) */
+    public record TradeQueryResult(boolean isPay, String message) {
+    }
+
+    // ------------------------------------------------------------------ 订单信息
+
+    /** 待支付订单信息 {order, personal, paymentMethods} */
+    public Map<String, Object> orderInfo(User user, int orderId, String client) {
+        Order order = requireUserOrder(user, orderId);
+        if (!Integer.valueOf(ORDER_STATUS_NORMAL).equals(order.getOrderStatus())) {
+            throw new BizException("当前订单状态不允许支付");
+        }
+        int closeHours = orderCloseHours();
+        int createTime = order.getCreateTime() == null ? 0 : order.getCreateTime();
+        long expirationTime = (long) createTime + (long) closeHours * 3600L;
+        if (closeHours > 0 && expirationTime <= now()) {
+            throw new BizException("当前订单支付已超时,请重新下单");
+        }
+
+        Map<String, Object> orderData = new LinkedHashMap<>();
+        orderData.put("orderId", order.getOrderId());
+        orderData.put("orderNo", order.getOrderNo());
+        orderData.put("payPrice", Money.number2(order.getPayPrice()));
+        orderData.put("payStatus", order.getPayStatus());
+        orderData.put("orderStatus", order.getOrderStatus());
+        orderData.put("createTime", createTime);
+        orderData.put("showExpiration", closeHours > 0);
+        orderData.put("expirationTime", TimeUtil.format((int) expirationTime));
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("order", orderData);
+        data.put("personal", apiUserService.node(user));
+        data.put("paymentMethods", apiPaymentService.methods(client, false));
+        return data;
+    }
+
+    // ------------------------------------------------------------------ 支付
+
+    /**
+     * 确认支付:余额支付当场完成结算;第三方支付返回唤起支付所需参数。
+     *
+     * @return {payment} 与提示文案(余额支付为「恭喜您,订单已付款成功」,其余为「下单成功」)
+     */
+    public PayResult orderPay(User user, int orderId, String method, String client) {
+        String payMethod = method == null ? "" : method.trim();
+        if (payMethod.isBlank()) {
+            throw new BizException("请选择支付方式");
+        }
+        Order order = requireUserOrder(user, orderId);
+        // 支付方式必须在当前客户端可用列表内
+        List<Map<String, Object>> methods = apiPaymentService.methods(client, false);
+        boolean enabled = methods.stream()
+                .anyMatch(item -> payMethod.equals(String.valueOf(item.get("method"))));
+        if (!enabled) {
+            throw new BizException("很抱歉,当前支付方式不可用");
+        }
+        checkOrderStatusOnPay(order);
+
+        if (ApiPaymentService.METHOD_BALANCE.equals(payMethod)) {
+            apiPaySuccessService.handle(order.getOrderNo(), payMethod);
+            return new PayResult(Map.of("payment", new ArrayList<>()), "恭喜您,订单已付款成功");
+        }
+        // 第三方支付:生成交易订单号 → 统一下单 → 记录交易
+        String outTradeNo = OrderNo.next();
+        Map<String, Object> payment = apiPaymentService.unifiedOrder(
+                payMethod, client, outTradeNo, Money.number2(order.getPayPrice()), null);
+        apiPaymentService.recordTrade(order, payMethod, client, ApiPaymentService.ORDER_TYPE_ORDER, payment);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("payment", payment);
+        return new PayResult(data, "下单成功");
+    }
+
+    /** 支付结果:data 内是 {payment},message 为提示文案 */
+    public record PayResult(Map<String, Object> data, String message) {
+    }
+
+    // ------------------------------------------------------------------ 交易查询
+
+    /** 查询第三方交易是否已支付 */
+    public TradeQueryResult tradeQuery(String outTradeNo, String method, String client) {
+        if (outTradeNo == null || outTradeNo.isBlank()) {
+            throw new BizException("商户订单号不能为空");
+        }
+        try {
+            boolean isPay = apiPaymentService.tradeQuery(method, client, outTradeNo);
+            return new TradeQueryResult(isPay, isPay
+                    ? "恭喜您,订单已付款成功" : "很抱歉,订单未支付,请重新发起");
+        } catch (BizException e) {
+            // 对齐 PHP:查询失败仍返回成功响应,message 取错误文案
+            return new TradeQueryResult(false, e.getMessage());
+        }
+    }
+
+    // ------------------------------------------------------------------ 校验
+
+    /** 订单存在性校验(限当前用户) */
+    private Order requireUserOrder(User user, int orderId) {
+        Order order = orderMapper.selectOne(Wrappers.<Order>lambdaQuery()
+                .eq(Order::getOrderId, orderId)
+                .eq(Order::getUserId, user.getUserId())
+                .eq(Order::getStoreId, ApiContext.STORE_ID)
+                .last("limit 1"));
+        if (order == null) {
+            throw new BizException("订单不存在");
+        }
+        return order;
+    }
+
+    /** 订单是否允许支付,对齐 PHP order\source\Basics::checkOrderStatusOnPayCommon + checkGoodsStatusOnPayCommon */
+    private void checkOrderStatusOnPay(Order order) {
+        if (!Integer.valueOf(ORDER_STATUS_NORMAL).equals(order.getOrderStatus())) {
+            throw new BizException("当前订单状态不合法,无法支付");
+        }
+        if (Integer.valueOf(PAY_STATUS_SUCCESS).equals(order.getPayStatus())) {
+            throw new BizException("当前订单已支付,无需重复支付");
+        }
+        List<OrderGoods> goodsList = orderGoodsMapper.selectList(Wrappers.<OrderGoods>lambdaQuery()
+                .eq(OrderGoods::getOrderId, order.getOrderId()));
+        for (OrderGoods item : goodsList) {
+            Goods goods = goodsMapper.selectById(item.getGoodsId());
+            if (goods == null || Integer.valueOf(GOODS_OFF_SALE).equals(goods.getStatus())) {
+                throw new BizException("很抱歉,商品 [" + item.getGoodsName() + "] 已下架");
+            }
+            GoodsSku sku = goodsSkuMapper.selectOne(Wrappers.<GoodsSku>lambdaQuery()
+                    .eq(GoodsSku::getGoodsId, item.getGoodsId())
+                    .eq(GoodsSku::getGoodsSkuId, item.getGoodsSkuId())
+                    .last("limit 1"));
+            if (sku == null) {
+                throw new BizException("很抱歉,商品 [" + item.getGoodsName() + "] SKU已不存在,请重新下单");
+            }
+            int totalNum = item.getTotalNum() == null ? 0 : item.getTotalNum();
+            if (Integer.valueOf(DEDUCT_STOCK_ON_PAYMENT).equals(item.getDeductStockType())
+                    && totalNum > (sku.getStockNum() == null ? 0 : sku.getStockNum())) {
+                throw new BizException("很抱歉,商品 [" + item.getGoodsName() + "] 库存不足");
+            }
+        }
+    }
+
+    /** 订单支付超时时间(小时),交易设置 trade.order.closeHours */
+    private int orderCloseHours() {
+        Object trade = apiSettingService.getItem("trade");
+        if (trade instanceof Map<?, ?> map && map.get("order") instanceof Map<?, ?> orderSetting) {
+            Object value = orderSetting.get("closeHours");
+            if (value == null) {
+                value = orderSetting.get("close_hours");
+            }
+            if (value != null) {
+                return Money.of(value).intValue();
+            }
+        }
+        return 0;
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+}

+ 199 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiNotifyService.java

@@ -0,0 +1,199 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.entity.PaymentTemplate;
+import cn.yoshop.common.entity.PaymentTrade;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.PaymentTemplateMapper;
+import cn.yoshop.common.mapper.PaymentTradeMapper;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.w3c.dom.Document;
+import org.w3c.dom.NodeList;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 第三方支付异步通知,对齐 PHP app/api/service/Notify。
+ *
+ * <p>三条回调链路:
+ * <ul>
+ *   <li>{@code /api/notify/wechatV2}:微信支付 V2,XML 报文,成功回 SUCCESS 的 XML;</li>
+ *   <li>{@code /api/notify/wechatV3}:微信支付 V3,JSON + 平台证书验签 + APIv3 密钥解密;</li>
+ *   <li>{@code /api/notify/alipay}:支付宝,表单参数 + RSA2 验签。</li>
+ * </ul>
+ *
+ * <p><b>验签为骨架</b>:微信 / 支付宝的证书与密钥尚未配置到 Java 侧(后台支付模板为空),
+ * 此时跳过验签直接从报文取交易号并分发;一旦库内出现支付模板(说明已配置真实商户),
+ * 验签缺失会直接返回失败,避免裸奔。接入网关后替换 {@link #verify} 即可。
+ *
+ * <p>分发按交易记录的订单类型处理:10 = 商城订单({@link ApiPaySuccessService}),
+ * 100 = 余额充值({@link ApiRechargeService#paySuccess})。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiNotifyService {
+
+    private static final Logger log = LoggerFactory.getLogger(ApiNotifyService.class);
+
+    private static final int ORDER_TYPE_ORDER = 10;
+    private static final int ORDER_TYPE_RECHARGE = 100;
+
+    /** 微信支付 V2 成功响应报文(微信要求 XML) */
+    private static final String WECHAT_V2_SUCCESS =
+            "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>";
+    /** 微信支付 V3 失败响应(成功时返回空串,PHP 同此行为) */
+    private static final String WECHAT_V3_FAIL = "{\"code\": \"FAIL\",\"message\": \"失败\"}";
+    /** 支付宝成功响应 */
+    private static final String ALIPAY_SUCCESS = "success";
+    private static final String ALIPAY_FAIL = "fail";
+
+    private final PaymentTradeMapper paymentTradeMapper;
+    private final PaymentTemplateMapper paymentTemplateMapper;
+    private final ApiPaySuccessService apiPaySuccessService;
+    private final ApiRechargeService apiRechargeService;
+
+    // ------------------------------------------------------------------ 微信支付 V2
+
+    /** 微信支付 V2 异步通知,返回微信要求的响应报文 */
+    public String wechatV2(String rawBody) {
+        try {
+            Map<String, Object> params = parseXml(rawBody);
+            String outTradeNo = str(params, "out_trade_no");
+            PaymentTrade trade = requireTrade(outTradeNo);
+            verify("微信支付 V2", outTradeNo);
+            Map<String, Object> paymentData = new LinkedHashMap<>();
+            paymentData.put("tradeNo", str(params, "transaction_id"));
+            paymentData.put("outTradeNo", outTradeNo);
+            dispatch(trade, paymentData);
+            return WECHAT_V2_SUCCESS;
+        } catch (Throwable e) {
+            log.warn("[notify] 微信支付 V2 处理失败: {}", e.getMessage());
+            return "FAIL";
+        }
+    }
+
+    // ------------------------------------------------------------------ 微信支付 V3
+
+    /** 微信支付 V3 异步通知,成功返回空串(PHP 一致),失败返回 JSON */
+    public String wechatV3(String rawBody, String serial) {
+        try {
+            // V3 的交易号在 resource 密文中,需要 APIv3 密钥解密后才能读取
+            throw new BizException("微信支付 V3 异步通知验签与解密尚未接入"
+                    + (serial == null || serial.isBlank() ? "" : "(平台证书序号 " + serial + ")"));
+        } catch (Throwable e) {
+            log.warn("[notify] 微信支付 V3 处理失败: {}", e.getMessage());
+            return WECHAT_V3_FAIL;
+        }
+    }
+
+    // ------------------------------------------------------------------ 支付宝
+
+    /** 支付宝异步通知,成功返回 success */
+    public String alipay(Map<String, Object> params) {
+        try {
+            String outTradeNo = str(params, "out_trade_no");
+            PaymentTrade trade = requireTrade(outTradeNo);
+            verify("支付宝", outTradeNo);
+            Map<String, Object> paymentData = new LinkedHashMap<>();
+            paymentData.put("tradeNo", str(params, "trade_no"));
+            paymentData.put("outTradeNo", outTradeNo);
+            dispatch(trade, paymentData);
+            return ALIPAY_SUCCESS;
+        } catch (Throwable e) {
+            log.warn("[notify] 支付宝处理失败: {}", e.getMessage());
+            return ALIPAY_FAIL;
+        }
+    }
+
+    // ------------------------------------------------------------------ 分发
+
+    /** 按交易记录的订单类型分发到商城订单 / 余额充值 */
+    private void dispatch(PaymentTrade trade, Map<String, Object> paymentData) {
+        int orderType = trade.getOrderType() == null ? 0 : trade.getOrderType();
+        if (orderType == ORDER_TYPE_ORDER) {
+            apiPaySuccessService.handle(trade.getOrderNo(), trade.getPayMethod(), trade.getTradeId(), paymentData);
+        } else if (orderType == ORDER_TYPE_RECHARGE) {
+            apiRechargeService.paySuccess(trade.getOrderNo(), trade.getPayMethod(), trade.getTradeId());
+        } else {
+            throw new BizException("未知的订单类型:" + orderType);
+        }
+        log.info("[notify] 订单支付成功 orderNo={} type={} method={}",
+                trade.getOrderNo(), orderType, trade.getPayMethod());
+    }
+
+    /** 按交易订单号查交易记录(PHP PaymentTrade::detailByOutTradeNo) */
+    private PaymentTrade requireTrade(String outTradeNo) {
+        if (outTradeNo == null || outTradeNo.isBlank()) {
+            throw new BizException("未获取到商户订单号");
+        }
+        PaymentTrade trade = paymentTradeMapper.selectOne(Wrappers.<PaymentTrade>lambdaQuery()
+                .eq(PaymentTrade::getOutTradeNo, outTradeNo)
+                .eq(PaymentTrade::getStoreId, ApiContext.STORE_ID)
+                .last("limit 1"));
+        if (trade == null) {
+            throw new BizException("第三方支付交易记录不存在 " + outTradeNo);
+        }
+        return trade;
+    }
+
+    /**
+     * 验签:库内尚无支付模板(未配置商户)时跳过,已配置则因为验签未接入而失败。
+     */
+    private void verify(String channel, String outTradeNo) {
+        boolean configured = paymentTemplateMapper.selectCount(
+                Wrappers.<PaymentTemplate>lambdaQuery()
+                        .eq(PaymentTemplate::getStoreId, ApiContext.STORE_ID)) > 0;
+        if (configured) {
+            throw new BizException(channel + "验签尚未接入,已拒绝该异步通知");
+        }
+        log.warn("[notify] {} 未配置商户,跳过验签 outTradeNo={}", channel, outTradeNo);
+    }
+
+    // ------------------------------------------------------------------ 报文解析
+
+    /** 解析微信支付 V2 的 XML 报文(一层结构,值可能带 CDATA) */
+    private static Map<String, Object> parseXml(String rawBody) {
+        if (rawBody == null || rawBody.isBlank()) {
+            throw new BizException("异步通知报文为空");
+        }
+        try {
+            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+            // 禁用外部实体,避免 XXE
+            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+            factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+            factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+            factory.setExpandEntityReferences(false);
+            Document document = factory.newDocumentBuilder()
+                    .parse(new ByteArrayInputStream(rawBody.getBytes(StandardCharsets.UTF_8)));
+            NodeList children = document.getDocumentElement().getChildNodes();
+            Map<String, Object> params = new LinkedHashMap<>();
+            for (int i = 0; i < children.getLength(); i++) {
+                var node = children.item(i);
+                if (node.getNodeType() != org.w3c.dom.Node.ELEMENT_NODE) {
+                    continue;
+                }
+                params.put(node.getNodeName(), node.getTextContent());
+            }
+            return params;
+        } catch (Exception e) {
+            throw new BizException("异步通知报文解析失败:" + e.getMessage());
+        }
+    }
+
+    private static String str(Map<String, Object> params, String key) {
+        Object value = params == null ? null : params.get(key);
+        return value == null ? "" : String.valueOf(value);
+    }
+}

+ 502 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiPassportService.java

@@ -0,0 +1,502 @@
+package cn.yoshop.api.service;
+
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.hutool.http.HttpUtil;
+import cn.yoshop.api.auth.ApiTokenStore;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.entity.UploadFile;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserOauth;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.UploadFileMapper;
+import cn.yoshop.common.mapper.UserMapper;
+import cn.yoshop.common.mapper.UserOauthMapper;
+import cn.yoshop.common.util.Params;
+import cn.yoshop.common.util.PhpStr;
+import cn.yoshop.store.service.ClientSettingService;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 客户端登录,对齐 PHP app/api/service/passport/{Login, Party, Captcha} 与 service/user/Oauth。
+ *
+ * <p>三种登录方式:
+ * <ol>
+ *   <li><b>手机号 + 短信验证码</b>({@link #login}):用户不存在则自动注册;</li>
+ *   <li><b>微信小程序一键登录</b>({@link #loginMpWx}):openid 存在则直接登录,
+ *       否则按「账户注册设置-强制绑定手机号」决定抛 {@code isBindMobile} 让前端跳绑定页;</li>
+ *   <li><b>微信小程序授权手机号登录</b>({@link #loginMpWxMobile}):解密 encryptedData 拿手机号后注册/登录。</li>
+ * </ol>
+ *
+ * <p>登录态:token = md5(storeId_millis_userId_guid_user_salt)(与 PHP 同构造),
+ * 写入 Redis {@code yoshop:api:token:{token}} → 用户 ID,有效期 30 天。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiPassportService {
+
+    private static final Logger log = LoggerFactory.getLogger(ApiPassportService.class);
+
+    /** 生成 token 的自定义盐,对齐 PHP Login::TOKEN_SALT */
+    private static final String TOKEN_SALT = "user_salt";
+    /** 登录态有效期(秒),对齐 PHP 缓存 86400 * 30 */
+    private static final long TOKEN_TTL_SECONDS = 30L * 86400;
+    /** 微信小程序渠道标识,对齐 PHP ClientEnum::MP_WEIXIN */
+    private static final String OAUTH_MP_WEIXIN = "MP-WEIXIN";
+    /** 微信 code2session 接口 */
+    private static final String WX_CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
+    /** 默认昵称前缀(PHP Party::getDefaultNickName) */
+    private static final Map<String, String> DEFAULT_NICK_NAMES = Map.of(
+            OAUTH_MP_WEIXIN, "微信用户",
+            "H5", "H5用户",
+            "APP", "APP用户");
+
+    private final UserMapper userMapper;
+    private final UserOauthMapper userOauthMapper;
+    private final UploadFileMapper uploadFileMapper;
+    private final ApiTokenStore tokenStore;
+    private final ApiSmsService smsService;
+    private final ApiSettingService apiSettingService;
+    private final ClientSettingService clientSettingService;
+
+    // ------------------------------------------------------------------ 手机号登录
+
+    /** 手机号 + 短信验证码登录(自动注册) */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> login(Map<String, Object> form, String platform) {
+        String smsCode = Params.str(form, "smsCode", "").trim();
+        String mobile = Params.str(form, "mobile", "").trim();
+        if (smsCode.isBlank()) {
+            throw new BizException("短信验证码不能为空");
+        }
+        if (mobile.isBlank()) {
+            throw new BizException("手机号不能为空");
+        }
+        checkMobile(mobile);
+        smsService.check(mobile, smsCode);
+
+        Map<String, Object> partyData = partyData(form);
+        Map<String, Object> wxSession = wxSessionOf(partyData);
+
+        User user = findByMobile(mobile);
+        if (user != null) {
+            touchLogin(user);
+        } else {
+            user = createUser(mobile, partyData, wxSession, platform);
+        }
+        // 第三方用户信息绑定(isParty 为真时才写 oauth)
+        if (Params.boolVal(form, "isParty")) {
+            createUserOauth(user.getUserId(), partyData, wxSession);
+        }
+        return logged(user);
+    }
+
+    // ------------------------------------------------------------------ 微信小程序登录
+
+    /** 微信小程序一键登录:openid 已注册直接登录,否则按设置决定是否强制绑定手机号 */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> loginMpWx(Map<String, Object> form, String platform) {
+        Map<String, Object> partyData = partyData(form);
+        String oauth = Params.str(partyData, "oauth", OAUTH_MP_WEIXIN);
+        Map<String, Object> wxSession = wxSessionOf(partyData, true);
+        String openid = str(wxSession, "openid");
+        if (openid.isBlank()) {
+            throw new BizException("微信登录失败:未获取到 openid");
+        }
+        Integer userId = findUserIdByOauth(openid, oauth);
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null && !Integer.valueOf(1).equals(user.getIsDelete())) {
+                // PHP 命中用户时只更新登录信息,不再改动头像昵称
+                touchLogin(user);
+                return logged(user);
+            }
+        }
+        // 未注册:账户注册设置-是否强制绑定手机号
+        if (registerFlag("isForceBindMpweixin", 1)) {
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("isBindMobile", true);
+            data.put("isPrompt", false);
+            throw new BizException("当前用户未绑定手机号").withData(data);
+        }
+        User user = createUser("", partyData, wxSession, platform);
+        createUserOauth(user.getUserId(), partyData, wxSession);
+        return logged(user);
+    }
+
+    /** 微信小程序授权手机号登录:解密 encryptedData 取手机号后注册/登录 */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> loginMpWxMobile(Map<String, Object> form, String platform) {
+        String code = Params.str(form, "code", "").trim();
+        Map<String, Object> wxSession = wxCode2Session(code);
+        String sessionKey = str(wxSession, "session_key");
+        Map<String, Object> plain = wxDecryptData(
+                Params.str(form, "encryptedData", ""),
+                Params.str(form, "iv", ""),
+                sessionKey);
+        String mobile = str(plain, "purePhoneNumber");
+        if (mobile.isBlank()) {
+            mobile = str(plain, "phoneNumber");
+        }
+        if (mobile.isBlank()) {
+            throw new BizException("微信登录失败:未获取到手机号");
+        }
+        checkMobile(mobile);
+
+        Map<String, Object> partyData = partyData(form);
+        User user = findByMobile(mobile);
+        if (user != null) {
+            touchLogin(user);
+        } else {
+            user = createUser(mobile, partyData, wxSession, platform);
+        }
+        createUserOauth(user.getUserId(), partyData, wxSession);
+        return logged(user);
+    }
+
+    /** 是否需要填写昵称头像(微信小程序端) */
+    public boolean isPersonalMpweixin(String code) {
+        if (!registerFlag("isPersonalMpweixin", 0)) {
+            return false;
+        }
+        Map<String, Object> wxSession = wxCode2Session(code);
+        String openid = str(wxSession, "openid");
+        return findUserIdByOauth(openid, OAUTH_MP_WEIXIN) == null;
+    }
+
+    // ------------------------------------------------------------------ 用户记录
+
+    private User findByMobile(String mobile) {
+        return userMapper.selectOne(Wrappers.<User>lambdaQuery()
+                .eq(User::getMobile, mobile)
+                .eq(User::getStoreId, ApiContext.STORE_ID)
+                .eq(User::getIsDelete, 0)
+                .last("limit 1"));
+    }
+
+    /** 更新用户登录信息(PHP Login::updateUser) */
+    private void touchLogin(User user) {
+        int now = now();
+        userMapper.update(null, Wrappers.<User>lambdaUpdate()
+                .set(User::getLastLoginTime, now)
+                .set(User::getStoreId, ApiContext.STORE_ID)
+                .set(User::getUpdateTime, now)
+                .eq(User::getUserId, user.getUserId()));
+        user.setLastLoginTime(now);
+    }
+
+    /** 新增用户(PHP Login::createUser) */
+    private User createUser(String mobile, Map<String, Object> partyData,
+                            Map<String, Object> wxSession, String platform) {
+        int now = now();
+        String nickName = mobile.isBlank() ? "" : ApiUserService.hideMobile(mobile);
+        Integer avatarId = null;
+
+        // 第三方用户信息优先(昵称 / 头像)
+        Map<String, Object> partyUserInfo = partyUserInfo(partyData, nickName.isBlank());
+        if (partyUserInfo.get("nickName") != null) {
+            nickName = String.valueOf(partyUserInfo.get("nickName"));
+        }
+        if (partyUserInfo.get("avatarId") != null) {
+            avatarId = (Integer) partyUserInfo.get("avatarId");
+        }
+
+        User user = new User();
+        user.setMobile(mobile.isEmpty() ? null : mobile);
+        user.setNickName(nickName);
+        user.setAvatarId(avatarId);
+        user.setPlatform(platform);
+        user.setLastLoginTime(now);
+        user.setStoreId(ApiContext.STORE_ID);
+        user.setIsDelete(0);
+        user.setCreateTime(now);
+        user.setUpdateTime(now);
+        userMapper.insert(user);
+
+        // 微信/支付宝默认昵称补编号,便于后台区分(PHP createUser 后续 save)
+        if ("微信用户".equals(nickName) || "支付宝用户".equals(nickName)) {
+            String numbered = nickName + "_" + user.getUserId();
+            userMapper.update(null, Wrappers.<User>lambdaUpdate()
+                    .set(User::getNickName, numbered)
+                    .eq(User::getUserId, user.getUserId()));
+            user.setNickName(numbered);
+        }
+        // 记录头像文件上传者
+        if (avatarId != null && avatarId > 0) {
+            uploadFileMapper.update(null, Wrappers.<UploadFile>lambdaUpdate()
+                    .set(UploadFile::getUploaderId, user.getUserId())
+                    .eq(UploadFile::getFileId, avatarId));
+        }
+        return user;
+    }
+
+    /** 保存第三方认证信息(PHP Party::createUserOauth) */
+    private void createUserOauth(int userId, Map<String, Object> partyData, Map<String, Object> wxSession) {
+        String oauth = Params.str(partyData, "oauth", "");
+        if (oauth.isBlank()) {
+            return;
+        }
+        String oauthId = str(wxSession, "openid");
+        if (oauthId.isBlank()) {
+            return;
+        }
+        UserOauth exists = userOauthMapper.selectOne(Wrappers.<UserOauth>lambdaQuery()
+                .eq(UserOauth::getUserId, userId)
+                .eq(UserOauth::getOauthType, oauth)
+                .eq(UserOauth::getIsDelete, 0)
+                .last("limit 1"));
+        if (exists == null) {
+            UserOauth record = new UserOauth();
+            record.setUserId(userId);
+            record.setOauthType(oauth);
+            record.setOauthId(oauthId);
+            String unionid = str(wxSession, "unionid");
+            record.setUnionid(unionid.isBlank() ? "" : unionid);
+            record.setStoreId(ApiContext.STORE_ID);
+            record.setIsDelete(0);
+            record.setCreateTime(now());
+            record.setUpdateTime(now());
+            userOauthMapper.insert(record);
+            return;
+        }
+        if (!oauthId.equals(exists.getOauthId())) {
+            // isBack 让前端返回重新获取 code(PHP 同义)
+            throw new BizException("很抱歉,当前手机号已绑定其他微信号")
+                    .withData(Map.of("isBack", true));
+        }
+    }
+
+    private Integer findUserIdByOauth(String oauthId, String oauthType) {
+        if (oauthId == null || oauthId.isBlank()) {
+            return null;
+        }
+        UserOauth record = userOauthMapper.selectOne(Wrappers.<UserOauth>lambdaQuery()
+                .eq(UserOauth::getOauthId, oauthId)
+                .eq(UserOauth::getOauthType, oauthType)
+                .eq(UserOauth::getIsDelete, 0)
+                .last("limit 1"));
+        return record == null ? null : record.getUserId();
+    }
+
+    /** 第三方用户昵称与头像(PHP Party::partyUserInfo,头像外链下载未接入) */
+    private Map<String, Object> partyUserInfo(Map<String, Object> partyData, boolean defaultNickName) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        Object rawUserInfo = partyData.get("userInfo");
+        Map<?, ?> userInfo = rawUserInfo instanceof Map<?, ?> map ? map : Map.of();
+        Object nickName = firstNonNull(userInfo, "nickName", "nick_name");
+        if (nickName != null && !String.valueOf(nickName).isBlank()) {
+            out.put("nickName", String.valueOf(nickName));
+        } else if (defaultNickName) {
+            String oauth = Params.str(partyData, "oauth", "");
+            out.put("nickName", DEFAULT_NICK_NAMES.getOrDefault(oauth, "商城用户"));
+        }
+        Object avatarId = firstNonNull(userInfo, "avatarId", "avatar_id");
+        Integer id = avatarId == null ? null : toInt(avatarId);
+        if (id != null && id > 0) {
+            out.put("avatarId", id);
+        }
+        return out;
+    }
+
+    // ------------------------------------------------------------------ 微信接口
+
+    /** 获取微信小程序登录态(openid / session_key / unionid) */
+    private Map<String, Object> wxSessionOf(Map<String, Object> partyData) {
+        return wxSessionOf(partyData, false);
+    }
+
+    /**
+     * @param required 是否必须拿到 session(微信快捷登录为 true,手机号登录的附带 partyData 为 false)
+     */
+    private Map<String, Object> wxSessionOf(Map<String, Object> partyData, boolean required) {
+        String oauth = Params.str(partyData, "oauth", "");
+        if (!oauth.isBlank() && !OAUTH_MP_WEIXIN.equals(oauth)) {
+            return Map.of();
+        }
+        String code = Params.str(partyData, "code", "");
+        if (code.isBlank()) {
+            if (required) {
+                throw new BizException("微信登录失败:code 不能为空");
+            }
+            return Map.of();
+        }
+        return wxCode2Session(code);
+    }
+
+    /** 微信 jscode2session:code 换 openid / session_key */
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> wxCode2Session(String code) {
+        if (code == null || code.isBlank()) {
+            throw new BizException("微信登录失败:code 不能为空");
+        }
+        String appId = wxappValue("app_id", "appId");
+        String appSecret = wxappValue("app_secret", "appSecret");
+        if (appId.isBlank() || appSecret.isBlank()) {
+            throw new BizException("请到后台小程序设置填写AppID和AppSecret参数");
+        }
+        String url = WX_CODE2SESSION_URL
+                + "?appid=" + encode(appId)
+                + "&secret=" + encode(appSecret)
+                + "&js_code=" + encode(code)
+                + "&grant_type=authorization_code";
+        String body;
+        try {
+            body = HttpUtil.get(url, 8000);
+        } catch (Exception e) {
+            log.warn("[wx] jscode2session 请求失败: {}", e.getMessage());
+            throw new BizException("微信登录失败:网络异常,请稍后重试");
+        }
+        Object decoded = PhpStr.jsonDecodeArray(body);
+        if (!(decoded instanceof Map<?, ?> json)) {
+            throw new BizException("微信登录失败:返回数据格式不正确");
+        }
+        int errcode = json.get("errcode") == null ? 0 : toInt(json.get("errcode"));
+        if (errcode != 0) {
+            String errmsg = json.get("errmsg") == null ? "未知错误" : String.valueOf(json.get("errmsg"));
+            throw new BizException("微信登录失败:" + errmsg);
+        }
+        String openid = json.get("openid") == null ? "" : String.valueOf(json.get("openid"));
+        if (openid.isBlank()) {
+            throw new BizException("微信登录失败:未获取到 openid");
+        }
+        Map<String, Object> session = new LinkedHashMap<>();
+        json.forEach((k, v) -> session.put(String.valueOf(k), v));
+        return session;
+    }
+
+    /** 解密微信加密数据(AES-128-CBC + PKCS#7,key 为 session_key) */
+    private Map<String, Object> wxDecryptData(String encryptedData, String iv, String sessionKey) {
+        if (encryptedData.isBlank() || iv.isBlank() || sessionKey.isBlank()) {
+            throw new BizException("微信数据 encryptedData 解密失败");
+        }
+        try {
+            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+            SecretKeySpec keySpec = new SecretKeySpec(Base64.getDecoder().decode(sessionKey), "AES");
+            IvParameterSpec ivSpec = new IvParameterSpec(Base64.getDecoder().decode(iv));
+            cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
+            byte[] plain = cipher.doFinal(Base64.getDecoder().decode(encryptedData));
+            Object decoded = PhpStr.jsonDecodeArray(new String(plain, StandardCharsets.UTF_8));
+            if (!(decoded instanceof Map<?, ?> json)) {
+                throw new BizException("微信数据 encryptedData 解密失败");
+            }
+            Map<String, Object> out = new LinkedHashMap<>();
+            json.forEach((k, v) -> out.put(String.valueOf(k), v));
+            return out;
+        } catch (BizException e) {
+            throw e;
+        } catch (Exception e) {
+            log.warn("[wx] encryptedData 解密失败: {}", e.getMessage());
+            throw new BizException("微信数据 encryptedData 解密失败");
+        }
+    }
+
+    /** 读取小程序设置项(库内可能是 app_id 或 appId 两种键) */
+    private String wxappValue(String snakeKey, String camelKey) {
+        Object item = clientSettingService.getWxappItem("basic", ApiContext.STORE_ID);
+        if (!(item instanceof Map<?, ?> values)) {
+            return "";
+        }
+        Object value = firstNonNull(values, snakeKey, camelKey);
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    // ------------------------------------------------------------------ 登录态
+
+    /** 生成 token 并写入登录态,返回 {userId, token} */
+    private Map<String, Object> logged(User user) {
+        String token = makeToken(user.getUserId());
+        tokenStore.save(token, user.getUserId(), TOKEN_TTL_SECONDS);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("userId", user.getUserId());
+        data.put("token", token);
+        return data;
+    }
+
+    /** token 构造与 PHP 一致:md5(storeId_时间戳_userId_guid_盐) */
+    private String makeToken(int userId) {
+        String raw = ApiContext.STORE_ID + "_" + System.currentTimeMillis() + "_" + userId
+                + "_" + UUID.randomUUID() + "_" + TOKEN_SALT;
+        return DigestUtil.md5Hex(raw);
+    }
+
+    // ------------------------------------------------------------------ 工具
+
+    private static Map<String, Object> partyData(Map<String, Object> form) {
+        Object raw = form.get("partyData");
+        if (!(raw instanceof Map<?, ?> map)) {
+            return new LinkedHashMap<>();
+        }
+        Map<String, Object> out = new LinkedHashMap<>();
+        map.forEach((k, v) -> out.put(String.valueOf(k), v));
+        return out;
+    }
+
+    /** 账户注册设置开关,缺省取 PHP 默认值 */
+    private boolean registerFlag(String key, int defaultValue) {
+        Object item = apiSettingService.getItem("register");
+        if (!(item instanceof Map<?, ?> values)) {
+            return defaultValue == 1;
+        }
+        Object value = values.get(key);
+        if (value == null) {
+            value = values.get(Params.camelToSnake(key));
+        }
+        return value == null ? defaultValue == 1 : toInt(value) == 1;
+    }
+
+    private static void checkMobile(String mobile) {
+        if (!mobile.matches("1\\d{10}")) {
+            throw new BizException("手机号格式不正确");
+        }
+    }
+
+    private static Object firstNonNull(Map<?, ?> map, String... keys) {
+        for (String key : keys) {
+            Object value = map.get(key);
+            if (value != null && !String.valueOf(value).isBlank()) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private static String str(Map<?, ?> map, String key) {
+        Object value = map == null ? null : map.get(key);
+        return value == null ? "" : String.valueOf(value);
+    }
+
+    private static int toInt(Object value) {
+        if (value instanceof Boolean b) {
+            return b ? 1 : 0;
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+}

+ 199 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiPaySuccessService.java

@@ -0,0 +1,199 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.entity.Goods;
+import cn.yoshop.common.entity.GoodsSku;
+import cn.yoshop.common.entity.Order;
+import cn.yoshop.common.entity.OrderGoods;
+import cn.yoshop.common.entity.PaymentTrade;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserBalanceLog;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.GoodsMapper;
+import cn.yoshop.common.mapper.GoodsSkuMapper;
+import cn.yoshop.common.mapper.OrderGoodsMapper;
+import cn.yoshop.common.mapper.OrderMapper;
+import cn.yoshop.common.mapper.PaymentTradeMapper;
+import cn.yoshop.common.mapper.UserBalanceLogMapper;
+import cn.yoshop.common.mapper.UserMapper;
+import cn.yoshop.common.util.Money;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 订单支付成功处理,对齐 PHP app\common\service\order\PaySuccess。
+ *
+ * <p>幂等约定:
+ * <ul>
+ *   <li>余额支付重复调用 → 抛「当前订单已支付,无需重复支付」(同步支付,重复即异常);</li>
+ *   <li>第三方支付重复回调 → 直接返回成功(异步通知可能重发)。</li>
+ * </ul>
+ *
+ * <p>支付成功时的事务动作:订单置已付款(pay_method / pay_status / pay_time / trade_id)、
+ * 累加用户总支付金额、累计商品实际销量(付款减库存的商品同时扣减商品与 SKU 库存)、
+ * 余额支付时扣减余额并写「用户消费」明细、第三方支付时把交易记录置为已支付。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiPaySuccessService {
+
+    private static final Logger log = LoggerFactory.getLogger(ApiPaySuccessService.class);
+
+    private static final String METHOD_BALANCE = "balance";
+    private static final String METHOD_WECHAT = "wechat";
+    private static final String METHOD_ALIPAY = "alipay";
+
+    private static final int PAY_STATUS_PENDING = 10;
+    private static final int PAY_STATUS_SUCCESS = 20;
+    private static final int ORDER_STATUS_NORMAL = 10;
+
+    private static final int DEDUCT_STOCK_ON_PAYMENT = 20;
+    private static final int BALANCE_SCENE_CONSUME = 20;
+    private static final int TRADE_STATE_SUCCESS = 20;
+
+    private final OrderMapper orderMapper;
+    private final OrderGoodsMapper orderGoodsMapper;
+    private final GoodsMapper goodsMapper;
+    private final GoodsSkuMapper goodsSkuMapper;
+    private final UserMapper userMapper;
+    private final UserBalanceLogMapper balanceLogMapper;
+    private final PaymentTradeMapper paymentTradeMapper;
+
+    /** 无第三方交易记录的场景(余额支付) */
+    public boolean handle(String orderNo, String method) {
+        return handle(orderNo, method, null, Map.of());
+    }
+
+    /**
+     * 订单支付成功业务处理。
+     *
+     * @param orderNo     商城订单号
+     * @param method      支付方式(balance / wechat / alipay)
+     * @param tradeId     第三方交易记录 ID,余额支付为 null
+     * @param paymentData 第三方支付成功返回的数据(含 tradeNo)
+     * @return 是否已处理(订单已是已付款状态时返回 true 表示幂等跳过)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public boolean handle(String orderNo, String method, Integer tradeId, Map<String, Object> paymentData) {
+        if (orderNo == null || orderNo.isBlank()) {
+            throw new BizException("orderNo not found");
+        }
+        if (method == null || method.isBlank()) {
+            throw new BizException("method not found");
+        }
+        Order order = orderMapper.selectOne(Wrappers.<Order>lambdaQuery()
+                .eq(Order::getOrderNo, orderNo)
+                .eq(Order::getStoreId, ApiContext.STORE_ID)
+                .last("limit 1"));
+        if (order == null) {
+            throw new BizException("未找到该订单信息");
+        }
+        // 已支付:余额支付视为重复提交,第三方视为重复回调
+        if (Integer.valueOf(PAY_STATUS_SUCCESS).equals(order.getPayStatus())) {
+            if (METHOD_BALANCE.equals(method)) {
+                throw new BizException("当前订单已支付,无需重复支付");
+            }
+            log.info("[pay] 订单已支付,忽略重复回调 orderNo={} method={}", orderNo, method);
+            return true;
+        }
+        if (!Integer.valueOf(ORDER_STATUS_NORMAL).equals(order.getOrderStatus())) {
+            throw new BizException("当前订单状态不合法,无法支付");
+        }
+        User user = userMapper.selectById(order.getUserId());
+        if (user == null) {
+            throw new BizException("未找到买家用户信息");
+        }
+        BigDecimal payPrice = Money.of(order.getPayPrice());
+        if (METHOD_BALANCE.equals(method) && Money.of(user.getBalance()).compareTo(payPrice) < 0) {
+            throw new BizException("账户余额不足,无法使用余额支付");
+        }
+
+        int now = now();
+        orderMapper.update(null, Wrappers.<Order>lambdaUpdate()
+                .set(Order::getPayMethod, method)
+                .set(Order::getPayStatus, PAY_STATUS_SUCCESS)
+                .set(Order::getPayTime, now)
+                .set(Order::getTradeId, tradeId == null ? 0 : tradeId)
+                .set(Order::getUpdateTime, now)
+                .eq(Order::getOrderId, order.getOrderId()));
+        // 累计用户总支付金额
+        userMapper.update(null, Wrappers.<User>lambdaUpdate()
+                .setSql("pay_money = pay_money + " + payPrice.toPlainString())
+                .eq(User::getUserId, order.getUserId()));
+
+        updateStockSales(order);
+        if (METHOD_BALANCE.equals(method)) {
+            updateBalanceRecord(order, payPrice, now);
+        }
+        if ((METHOD_WECHAT.equals(method) || METHOD_ALIPAY.equals(method)) && tradeId != null) {
+            Object tradeNo = paymentData == null ? null : paymentData.get("tradeNo");
+            paymentTradeMapper.update(null, Wrappers.<PaymentTrade>lambdaUpdate()
+                    .set(PaymentTrade::getTradeState, TRADE_STATE_SUCCESS)
+                    .set(PaymentTrade::getTradeNo, tradeNo == null ? "" : String.valueOf(tradeNo))
+                    .set(PaymentTrade::getUpdateTime, now)
+                    .eq(PaymentTrade::getTradeId, tradeId));
+        }
+        order.setPayStatus(PAY_STATUS_SUCCESS);
+        order.setPayTime(now);
+        order.setPayMethod(method);
+        return true;
+    }
+
+    /** 累计商品实际销量;付款减库存的商品同步扣减商品总库存与 SKU 库存 */
+    private void updateStockSales(Order order) {
+        List<OrderGoods> goodsList = orderGoodsMapper.selectList(Wrappers.<OrderGoods>lambdaQuery()
+                .eq(OrderGoods::getOrderId, order.getOrderId()));
+        for (OrderGoods goods : goodsList) {
+            int num = goods.getTotalNum() == null ? 0 : goods.getTotalNum();
+            if (num <= 0) {
+                continue;
+            }
+            boolean deductOnPayment = Integer.valueOf(DEDUCT_STOCK_ON_PAYMENT).equals(goods.getDeductStockType());
+            var update = Wrappers.<Goods>lambdaUpdate()
+                    .setSql("sales_actual = sales_actual + " + num)
+                    .eq(Goods::getGoodsId, goods.getGoodsId());
+            if (deductOnPayment) {
+                update.setSql("stock_total = stock_total - " + num);
+            }
+            goodsMapper.update(null, update);
+            if (!deductOnPayment) {
+                continue;
+            }
+            goodsSkuMapper.update(null, Wrappers.<GoodsSku>lambdaUpdate()
+                    .setSql("stock_num = stock_num - " + num)
+                    .eq(GoodsSku::getGoodsId, goods.getGoodsId())
+                    .eq(GoodsSku::getGoodsSkuId, goods.getGoodsSkuId()));
+        }
+    }
+
+    /** 余额支付:扣减余额并写「用户消费」明细 */
+    private void updateBalanceRecord(Order order, BigDecimal payPrice, int now) {
+        if (payPrice.compareTo(BigDecimal.ZERO) <= 0) {
+            return;
+        }
+        userMapper.update(null, Wrappers.<User>lambdaUpdate()
+                .setSql("balance = balance - " + payPrice.toPlainString())
+                .eq(User::getUserId, order.getUserId()));
+        UserBalanceLog record = new UserBalanceLog();
+        record.setUserId(order.getUserId());
+        record.setScene(BALANCE_SCENE_CONSUME);
+        record.setMoney(payPrice.negate());
+        record.setDescribe("用户消费:" + order.getOrderNo());
+        record.setStoreId(order.getStoreId());
+        record.setCreateTime(now);
+        balanceLogMapper.insert(record);
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+}

+ 147 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiPaymentService.java

@@ -0,0 +1,147 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.entity.Order;
+import cn.yoshop.common.entity.PaymentTrade;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.PaymentTradeMapper;
+import cn.yoshop.common.util.OrderNo;
+import cn.yoshop.store.service.PaymentSettingService;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端支付相关的公共能力(收银台与余额充值共用),对齐 PHP:
+ * {@code app\common\model\Payment}(getMethodsByClient / getPaymentInfo)、
+ * {@code app\api\model\PaymentTrade}(record)与 {@code common\library\payment\Facade}。
+ *
+ * <p><b>第三方支付为骨架</b>:微信支付 / 支付宝的网关调用(统一下单、交易查询、异步通知验签)
+ * 需要商户号、API 密钥、平台证书等配置,当前统一抛出明确文案,待接入后替换
+ * {@link #unifiedOrder} / {@link #tradeQuery} 的实现即可,调用方无需改动。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiPaymentService {
+
+    /** 第三方支付方式标识 */
+    public static final String METHOD_WECHAT = "wechat";
+    public static final String METHOD_ALIPAY = "alipay";
+    /** 余额支付标识 */
+    public static final String METHOD_BALANCE = "balance";
+
+    /** 订单类型:商城订单 */
+    public static final int ORDER_TYPE_ORDER = 10;
+    /** 交易状态:未支付 */
+    private static final int TRADE_STATE_UNPAID = 10;
+
+    private final PaymentSettingService paymentSettingService;
+    private final PaymentTradeMapper paymentTradeMapper;
+
+    /**
+     * 指定客户端启用的支付方式(PHP Payment::getMethodsByClient)。
+     *
+     * @param excludeBalance 余额充值场景排除余额支付
+     */
+    @SuppressWarnings("unchecked")
+    public List<Map<String, Object>> methods(String client, boolean excludeBalance) {
+        Object group = paymentSettingService.options(ApiContext.STORE_ID).get(client == null ? "" : client);
+        List<Map<String, Object>> methods = new ArrayList<>();
+        if (group instanceof Map<?, ?> g && g.get("methods") instanceof List<?> list) {
+            for (Object item : list) {
+                if (!(item instanceof Map<?, ?> method)) {
+                    continue;
+                }
+                boolean enable = Boolean.TRUE.equals(method.get("isEnable"))
+                        || Integer.valueOf(1).equals(method.get("isEnable"));
+                if (!enable) {
+                    continue;
+                }
+                if (excludeBalance && METHOD_BALANCE.equals(String.valueOf(method.get("method")))) {
+                    continue;
+                }
+                methods.add((Map<String, Object>) method);
+            }
+        }
+        if (methods.isEmpty()) {
+            throw new BizException("很抱歉,当前没有可用的支付方式,请检查后台支付设置");
+        }
+        return methods;
+    }
+
+    /** 是否为第三方支付(微信 / 支付宝) */
+    public static boolean isThirdParty(String method) {
+        return METHOD_WECHAT.equals(method) || METHOD_ALIPAY.equals(method);
+    }
+
+    /**
+     * 第三方统一下单,返回客户端唤起支付所需的参数。
+     * 非第三方支付(余额等)按 PHP 语义返回空 Map。
+     */
+    public Map<String, Object> unifiedOrder(String method, String client, String outTradeNo,
+                                            String payPrice, String openid) {
+        if (!isThirdParty(method)) {
+            return Map.of();
+        }
+        String label = METHOD_WECHAT.equals(method) ? "微信支付" : "支付宝";
+        throw new BizException("很抱歉," + label + "尚未接入(缺少商户号与密钥配置),暂时无法完成支付");
+    }
+
+    /** 交易查询:返回是否已支付;未接入时返回 false 并给出文案 */
+    public boolean tradeQuery(String method, String client, String outTradeNo) {
+        if (!isThirdParty(method)) {
+            return false;
+        }
+        throw new BizException("很抱歉,第三方支付查询(微信 / 支付宝)尚未接入");
+    }
+
+    /**
+     * 记录第三方交易(PHP api\model\PaymentTrade::record):
+     * 同一订单 + 支付方式 + 客户端已存在记录时更新,否则新增。
+     */
+    public void recordTrade(Order order, String method, String client, int orderType, Map<String, Object> payment) {
+        PaymentTrade exists = paymentTradeMapper.selectOne(Wrappers.<PaymentTrade>lambdaQuery()
+                .eq(PaymentTrade::getOrderId, order.getOrderId())
+                .eq(PaymentTrade::getOrderType, orderType)
+                .eq(PaymentTrade::getClient, client == null ? "" : client)
+                .eq(PaymentTrade::getPayMethod, method)
+                .last("limit 1"));
+        int now = now();
+        String outTradeNo = payment == null || payment.get("outTradeNo") == null
+                ? OrderNo.next() : String.valueOf(payment.get("outTradeNo"));
+        String prepayId = payment == null || payment.get("prepayId") == null
+                ? "" : String.valueOf(payment.get("prepayId"));
+        if (exists == null) {
+            PaymentTrade record = new PaymentTrade();
+            record.setClient(client == null ? "" : client);
+            record.setPayMethod(method);
+            record.setOrderType(orderType);
+            record.setOrderId(order.getOrderId());
+            record.setOrderNo(order.getOrderNo());
+            record.setUserId(order.getUserId());
+            record.setOutTradeNo(outTradeNo);
+            record.setPrepayId(prepayId);
+            record.setTradeState(TRADE_STATE_UNPAID);
+            record.setStoreId(ApiContext.STORE_ID);
+            record.setCreateTime(now);
+            record.setUpdateTime(now);
+            paymentTradeMapper.insert(record);
+            return;
+        }
+        paymentTradeMapper.update(null, Wrappers.<PaymentTrade>lambdaUpdate()
+                .set(PaymentTrade::getOutTradeNo, outTradeNo)
+                .set(PaymentTrade::getPrepayId, prepayId)
+                .set(PaymentTrade::getTradeState, TRADE_STATE_UNPAID)
+                .set(PaymentTrade::getUpdateTime, now)
+                .eq(PaymentTrade::getTradeId, exists.getTradeId()));
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+}

+ 2 - 20
yoshop-java/src/main/java/cn/yoshop/api/service/ApiRechargeService.java

@@ -77,6 +77,7 @@ public class ApiRechargeService {
     private final UserMapper userMapper;
     private final UserBalanceLogMapper balanceLogMapper;
     private final PaymentSettingService paymentSettingService;
+    private final ApiPaymentService apiPaymentService;
     private final ApiSettingService apiSettingService;
     private final ApiUserService apiUserService;
     private final ApiView view;
@@ -128,27 +129,8 @@ public class ApiRechargeService {
     }
 
     /** 指定客户端启用的支付方式(余额充值场景排除余额支付,PHP Payment::getMethodsByClient) */
-    @SuppressWarnings("unchecked")
     public List<Map<String, Object>> paymentMethods(String client) {
-        Object group = paymentSettingService.options(ApiContext.STORE_ID).get(client == null ? "" : client);
-        List<Map<String, Object>> methods = new ArrayList<>();
-        if (group instanceof Map<?, ?> g && g.get("methods") instanceof List<?> list) {
-            for (Object item : list) {
-                if (!(item instanceof Map<?, ?> method)) {
-                    continue;
-                }
-                boolean enable = Boolean.TRUE.equals(method.get("isEnable"))
-                        || Integer.valueOf(1).equals(method.get("isEnable"));
-                if (!enable || "balance".equals(String.valueOf(method.get("method")))) {
-                    continue;
-                }
-                methods.add((Map<String, Object>) method);
-            }
-        }
-        if (methods.isEmpty()) {
-            throw new BizException("很抱歉,当前没有可用的支付方式,请检查后台支付设置");
-        }
-        return methods;
+        return apiPaymentService.methods(client, true);
     }
 
     // ------------------------------------------------------------------ 下单

+ 14 - 3
yoshop-java/src/main/java/cn/yoshop/api/service/ApiSmsService.java

@@ -4,7 +4,10 @@ import cn.yoshop.common.exception.BizException;
 import cn.yoshop.common.util.PhpStr;
 import cn.yoshop.utils.RedisUtils;
 
+import cn.hutool.core.util.RandomUtil;
 import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.util.LinkedHashMap;
@@ -29,6 +32,8 @@ import java.util.Map;
 @RequiredArgsConstructor
 public class ApiSmsService {
 
+    private static final Logger log = LoggerFactory.getLogger(ApiSmsService.class);
+
     /** 验证码缓存键前缀 */
     public static final String KEY_PREFIX = "yoshop:api:sms:";
     /** 有效期(秒),对齐 PHP CaptchaApi::$expire */
@@ -47,12 +52,18 @@ public class ApiSmsService {
     }
 
     /**
-     * 发送短信验证码。
+     * 生成并下发短信验证码(6 位数字)。
+     *
+     * <p><b>短信通道尚未接入</b>:验证码照样写入 Redis,同时以 WARN 日志打印,
+     * 便于本地联调时从日志取值;接口层仍返回「发送成功,请注意查收」(对齐 PHP 当前行为)。
      *
-     * @return 生成的验证码(接入通道后返回 null 表示已下发)
+     * @return 生成的验证码
      */
     public String send(String mobile) {
-        throw new BizException("很抱歉,短信服务尚未配置,暂无法发送验证码");
+        String code = String.valueOf(RandomUtil.randomInt(100000, 1000000));
+        saveCode(mobile, code);
+        log.warn("[sms] 短信通道未配置,验证码仅写入缓存:mobile={} code={}", mobile, code);
+        return code;
     }
 
     /**

+ 39 - 0
yoshop-java/src/main/java/cn/yoshop/common/entity/UserOauth.java

@@ -0,0 +1,39 @@
+package cn.yoshop.common.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+
+/**
+ * 第三方登录绑定(yoshop_user_oauth),对齐 PHP app\common\model\UserOauth。
+ *
+ * <p>一个用户在同一渠道(oauthType,如 MP-WEIXIN)最多一条记录,oauthId 即微信 openid。
+ */
+@Data
+@TableName("yoshop_user_oauth")
+public class UserOauth {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    private Integer userId; // 用户ID
+
+    private String oauthType; // 第三方登录类型(MP-WEIXIN / H5 / APP)
+
+    private String oauthId; // 第三方用户唯一标识(openid)
+
+    private String unionid; // 微信 unionid,可能为空
+
+    private Integer storeId; // 商城ID
+
+    @JsonIgnore
+    private Integer isDelete;
+
+    @JsonIgnore
+    private Integer createTime;
+
+    @JsonIgnore
+    private Integer updateTime;
+}

+ 18 - 0
yoshop-java/src/main/java/cn/yoshop/common/exception/BizException.java

@@ -1,5 +1,7 @@
 package cn.yoshop.common.exception;
 
+import java.util.Map;
+
 /**
  * 业务异常,对齐 PHP 端 cores/exception/BaseException。
  * status 默认 500,可指定 401/403 等业务状态码。
@@ -17,6 +19,9 @@ public class BizException extends RuntimeException {
     /** true = 异常风格(data.isPrompt);false = renderError 风格(data 为空数组) */
     private final boolean prompt;
 
+    /** 附加到 data 的字段(对齐 PHP throwError 第三个参数 $data),可为空 */
+    private Map<String, Object> data;
+
     public BizException(String message) {
         this(message, 500, true);
     }
@@ -43,4 +48,17 @@ public class BizException extends RuntimeException {
     public boolean isPrompt() {
         return prompt;
     }
+
+    public Map<String, Object> getData() {
+        return data;
+    }
+
+    /**
+     * 附加业务数据,对齐 PHP {@code throwError($msg, null, ['isBindMobile' => true])}。
+     * 合并顺序为 {@code {"isPrompt": true}} 之后再覆盖,因此可显式传 {@code isPrompt=false}。
+     */
+    public BizException withData(Map<String, Object> extra) {
+        this.data = extra;
+        return this;
+    }
 }

+ 16 - 2
yoshop-java/src/main/java/cn/yoshop/common/exception/GlobalExceptionHandler.java

@@ -7,6 +7,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
@@ -23,7 +24,7 @@ public class GlobalExceptionHandler {
             // 对齐 PHP renderError:data 恒为空数组
             return new R<>(e.getStatus(), e.getMessage(), new Object[0]);
         }
-        return failWithPrompt(e.getStatus(), e.getMessage());
+        return failWithPrompt(e.getStatus(), e.getMessage(), e.getData());
     }
 
     @ExceptionHandler(MethodArgumentNotValidException.class)
@@ -57,6 +58,19 @@ public class GlobalExceptionHandler {
      * (PHP 用 array_merge(['isPrompt' => true], $e->data)),前端据此弹提示。
      */
     private static R<Void> failWithPrompt(int status, String message) {
-        return new R<>(status, message, Map.of("isPrompt", true));
+        return failWithPrompt(status, message, null);
+    }
+
+    /**
+     * 与 PHP {@code array_merge(['isPrompt' => true], $e->data)} 一致:
+     * 先放 isPrompt,再用异常附加数据覆盖(可显式传 isPrompt=false)。
+     */
+    private static R<Void> failWithPrompt(int status, String message, Map<String, Object> extra) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("isPrompt", true);
+        if (extra != null) {
+            data.putAll(extra);
+        }
+        return new R<>(status, message, data);
     }
 }

+ 3 - 1
yoshop-java/src/main/java/cn/yoshop/common/mapper/UserOauthMapper.java

@@ -1,5 +1,7 @@
 package cn.yoshop.common.mapper;
 
+import cn.yoshop.common.entity.UserOauth;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Update;
@@ -8,7 +10,7 @@ import org.apache.ibatis.annotations.Update;
  * 第三方登录绑定(yoshop_user_oauth)。删除会员时同步标记删除。
  */
 @Mapper
-public interface UserOauthMapper {
+public interface UserOauthMapper extends BaseMapper<UserOauth> {
 
     @Update("UPDATE yoshop_user_oauth SET is_delete = 1 WHERE user_id = #{userId} AND is_delete = 0")
     int softDeleteByUserId(@Param("userId") int userId);

+ 127 - 0
接口实现进度-第13批-api.md

@@ -0,0 +1,127 @@
+# 接口实现进度(第 13 批 · 客户端 api 第 4 阶段,收官)
+
+> 目标:把 PHP `app/api`(客户端:微信小程序 / H5)重写为 Java。
+> 接口总量 74 个,本批次为**第 4 批:登录 / 验证码 / 收银台 / 支付回调(12 个)**。
+> 累计:**74 / 74,客户端 api 全部实现**。
+
+## 一、本批已实现(12 / 74)
+
+| 分组 | 接口 | 说明 |
+|---|---|---|
+| 登录 | `passport/login` | 手机号 + 短信验证码,用户不存在自动注册;`userId` / `token` |
+| | `passport/loginMpWx` | 微信小程序一键登录:openid 已注册直接登录,未注册按「强制绑定手机号」配置抛 `isBindMobile` |
+| | `passport/loginMpWxMobile` | 小程序授权手机号登录:`jscode2session` 取 session_key → AES 解密 `encryptedData` → 手机号 |
+| | `passport/isPersonalMpweixin` | 是否需要填写昵称头像(设置关闭时直接 false,不请求微信) |
+| 验证码 | `captcha/image` | 5 位图形验证码,返回 `{base64, key, md5}`,key 为验证码小写的 BCrypt 摘要 |
+| | `captcha/sendSmsCaptcha` | 校验图形验证码 → 手机号每日限 10 次 → 生成 6 位短信验证码入库 |
+| 收银台 | `cashier/orderInfo` | 待支付订单(含超时时间)+ 当前用户 + 该客户端可用支付方式(含余额) |
+| | `cashier/orderPay` | 余额支付当场结算;第三方支付返回唤起参数并记录交易 |
+| | `cashier/tradeQuery` | 第三方交易是否已支付 |
+| 支付回调 | `notify/wechatV2` | 微信 V2 XML 报文 → 交易记录 → 支付成功分发 → 回 SUCCESS 的 XML |
+| | `notify/wechatV3` | 微信 V3 JSON + 平台证书验签(未接入,回失败 JSON) |
+| | `notify/alipay` | 支付宝表单参数 → 交易记录 → 支付成功分发 → 回 `success` |
+
+## 二、代码结构
+
+```
+api/service/ApiPassportService       # 三种登录 + 微信 jscode2session / AES 解密 + token 生成
+api/service/ApiCaptchaService        # 图形验证码(生成/校验)+ 短信验证码下发与次数限制
+api/service/ApiSmsService            # 短信验证码读写与校验(本批补下发,通道仍为骨架)
+api/service/ApiCashierService        # 收银台三接口 + 订单可支付校验
+api/service/ApiPaySuccessService     # 订单支付成功结算(余额/第三方共用,含幂等)
+api/service/ApiPaymentService        # 支付方式过滤、统一下单骨架、交易记录写入
+api/service/ApiNotifyService         # 三种异步通知的解析、验签骨架与分发
+
+api/controller/ApiPassportController
+api/controller/ApiCaptchaController
+api/controller/ApiCashierController
+api/controller/ApiNotifyController
+
+common/entity/UserOauth              # 新增(第三方登录绑定),UserOauthMapper 改为 BaseMapper
+common/exception/BizException        # 支持附加 data(isBindMobile / isBack),全局异常合并输出
+```
+
+## 三、关键口径
+
+### 1. 登录
+
+| 项 | 规则 |
+|---|---|
+| 手机号 | 必填且需匹配 `1\d{10}`(PHP 只校验非空,这里收紧以免注册垃圾账号),格式不符提示「手机号格式不正确」 |
+| 验证码 | 复用 `ApiSmsService.check`:不存在 / 次数用尽 / 不匹配三种文案与 PHP 一致,通过即删 |
+| 自动注册 | 昵称取 `139****1111`(`hide_mobile`);`platform` 取请求头;`last_login_time` / `store_id` 落库 |
+| token | `md5(storeId_millis_userId_guid_user_salt)`,与 PHP 同构造;写入 `yoshop:api:token:{token}` → 用户 ID,TTL 30 天 |
+| 微信登录 | `jscode2session` 读 `yoshop_wxapp_setting` 的 `app_id` / `app_secret`(兼容 `appId` / `appSecret` 键);未配置时提示「请到后台小程序设置填写AppID和AppSecret参数」 |
+| 强制绑定 | 未注册且 `register.isForceBindMpweixin=1` → `status=500`,`data={isPrompt:false, isBindMobile:true}` |
+| oauth 绑定 | 同一用户同一渠道只一条;已绑定其他 openid → `data={isBack:true}`,提示「当前手机号已绑定其他微信号」 |
+| 头像昵称 | 来自 `partyData.userInfo`;微信默认昵称「微信用户」追加 `_{userId}`;头像上传者记为新用户 |
+
+### 2. 验证码
+
+- 图形验证码用 `hutool` 的 `LineCaptcha`(160×60、5 位、40 条干扰线),
+  Redis 键 `yoshop:api:captcha:{key}`(key = `$2a$10$...`),值 `{"code","times":5}`,TTL 1800 秒;
+  校验失败次数减一、次数用尽删除、通过即删;`md5` 为验证码明文的 MD5(对齐 PHP 出参)。
+- 短信验证码:先校验图形验证码,再按手机号统计发送次数(`yoshop:api:sms:send:{mobile}`,10 次 / 24 小时),
+  生成 6 位数字写入 `yoshop:api:sms:{mobile}`;**通道未接入**时只写缓存并打 `[sms] ... code=xxxxxx` 日志。
+
+### 3. 收银台
+
+- `orderInfo`:订单必须属于当前用户;`order_status != 10` 报「当前订单状态不允许支付」;
+  超时时间取 `trade.order.closeHours`(当前 24 小时),超时报「当前订单支付已超时,请重新下单」;
+  `payPrice` 为两位小数字符串,`expirationTime` 格式 `Y-m-d H:i:s`。
+- `orderPay`:支付方式必须在当前客户端可用列表内;订单状态校验顺序
+  「状态不合法 → 已支付 → 商品下架 / SKU 不存在 / 库存不足(仅付款减库存商品)」;
+  余额支付直接调 `ApiPaySuccessService`,第三方支付走统一下单 + 写 `yoshop_payment_trade`。
+- `tradeQuery`:非第三方支付返回 `isPay=false`;第三方未接入时仍返回 `status=200`,`message` 带未接入文案(对齐 PHP 的 error 兜底)。
+
+### 4. 支付成功结算(`ApiPaySuccessService`)
+
+订单置 `pay_method` / `pay_status=20` / `pay_time` / `trade_id` → 用户 `pay_money` 累加 →
+商品 `sales_actual` 累加(付款减库存商品同时扣 `stock_total` 与 SKU `stock_num`)→
+余额支付扣余额并写「用户消费:{订单号}」明细(scene=20)→ 第三方支付把交易记录置 `trade_state=20` 并写 `trade_no`。
+
+幂等:余额支付重复调用报「当前订单已支付,无需重复支付」;第三方重复回调直接返回成功。
+
+### 5. 支付回调(`ApiNotifyService`)
+
+- 解析:V2 用 DOM 解析 XML(禁用 DOCTYPE 与外部实体,防 XXE);支付宝读表单参数;V3 需 APIv3 密钥解密 `resource`。
+- 分发:按交易记录 `order_type` — 10 → 商城订单结算,100 → 余额充值结算(`ApiRechargeService.paySuccess`)。
+- **验签为骨架**:库内没有支付模板(未配置商户)时跳过验签并 `WARN` 记录;一旦存在支付模板即拒绝回调,
+  避免「配了真商户却裸奔」。V3 因报文加密未解密成功,统一返回 `{"code": "FAIL","message": "失败"}`。
+- 响应体不是统一 `R`:V2 回微信要求的 XML、V3 回 JSON、支付宝回小写 `success` / `fail`。
+
+## 四、验证
+
+`python tools/test_api_batch4.py`(需 18080 在跑):**77 项断言全过**,连跑两次结果一致,
+跑完自动清理订单 / 交易记录 / 充值单 / 支付方式 / 测试用户,并恢复用户余额、总支付金额、商品销量。
+
+覆盖点:
+- 图形验证码:出参三键、PNG data URI、key 为 BCrypt、Redis 写入与 TTL、`md5` 与缓存明文一致;
+- 短信:缺参 / 图形验证码错误 / key 为空 / 发送成功 / 用后即焚 / 每日 10 次上限(第 11 次被拒且计数归 0);
+- 登录:缺短信验证码、缺手机号、验证码不存在、手机号格式、验证码错误(次数减一)、
+  登录成功(token 32 位)、验证码用后即焚、新用户落库(打码昵称 + platform)、token 可用、
+  重复登录复用同一账号;
+- 小程序登录:缺 code、未配置 AppID/AppSecret、授权手机号登录同样受限、`isPersonalMpweixin=false`、零 snake key;
+- 收银台:401、订单不存在、已取消订单不可支付、订单金额与超时字段、支付方式含余额、零 snake key、
+  缺支付方式、支付方式不可用、余额不足、微信支付未接入文案、余额支付全链路
+  (订单已付款 / 余额扣减 / `pay_money` 累加 / 消费明细 / 销量累加)、重复支付被拒、
+  交易查询三种分支;
+- 回调:V2 未知单号与坏报文回 FAIL、V3 回失败 JSON、V2 有效回调置订单已付款并回 SUCCESS、重复回调幂等、
+  支付宝回调置订单已付款并回 success、未知单号回 fail、充值订单回调到账 + 写充值明细。
+
+## 五、客户端 api 整体进度(74 / 74)
+
+| 批次 | 范围 | 接口数 | 验证 |
+|---|---|---|---|
+| 第 1 批 | 首页 / 店铺 / 地区 / 文章 / 商品 / 营销 | 22 | 52 项 |
+| 第 2 批 | 购物车 / 结算下单 / 订单 / 评价 / 地址 | 22 | 97 项 |
+| 第 3 批 | 用户中心 / 优惠券 / 明细 / 售后 / 充值 / 上传 | 18 | 99 项 |
+| 第 4 批 | 登录 / 验证码 / 收银台 / 支付回调 | 12 | 77 项 |
+
+## 六、切流前仍需补齐(按优先级)
+
+1. **`checkPrivilege` 接口级鉴权**(商家端,第一优先级,与本批无关);
+2. 第三方支付网关:微信 V2/V3 统一下单、订单查询、退款、异步通知验签与证书管理;支付宝 RSA2 签名的下单 / 查询 / 回调;
+3. 短信通道:阿里云 / 腾讯云 / 七牛云,接入后只需替换 `ApiSmsService.send`;
+4. 微信头像外链下载入库(`PartyService::partyAvatar`,当前只认已上传的 `avatarId`);
+5. 微信小程序发货信息上报、物流轨迹(快递100 / 快递鸟)查询。

+ 17 - 7
联调说明.md

@@ -116,19 +116,29 @@ NODE_OPTIONS=--openssl-legacy-provider ./node_modules/.bin/vue-cli-service serve
 
 ## 七、客户端(api)联调
 
-客户端 api 共 74 个接口,已分三批实现 62 个,详见 `接口实现进度-第10批-api.md` /
-`接口实现进度-第11批-api.md` / `接口实现进度-第12批-api.md`。
+客户端 api 共 74 个接口,**已全部实现**(四批),详见 `接口实现进度-第10批-api.md` 起至
+`接口实现进度-第13批-api.md`。
 
 - 基础地址:`/api/**`,与 PHP 的 `/index.php?s=/api/xxx` 对应
 - 请求头:`platform`(MP-WEIXIN / H5)、`Access-Token`(用户 token,可无)
 - 登录态:Redis `yoshop:api:token:{token}`,值=用户 ID;`/api/**` 只解析不拦截,
-  需登录的接口(购物车、订单、用户中心、售后、充值等)由接口内部判定并返回 401「请先登录后再访问」
-- 短信验证码:Redis `yoshop:api:sms:{mobile}` → `{"code":"123456","times":5}`,TTL 1800 秒;
-  发送通道未接入(`/api/captcha/sms` 在下一批),接入前可通过该键直接写入验证码联调
+  需登录的接口(购物车、订单、用户中心、售后、充值、收银台等)由接口内部判定并返回 401「请先登录后再访问」
+- 登录:`/api/passport/login`(手机号 + 短信验证码,自动注册)、`/api/passport/loginMpWx`(小程序一键登录)、
+  `/api/passport/loginMpWxMobile`(小程序授权手机号);返回 `{userId, token}`,token 有效期 30 天
+- 验证码:`/api/captcha/image` 返回 `{base64, key, md5}`(Redis `yoshop:api:captcha:{key}`,TTL 1800 秒);
+  `/api/captcha/sendSmsCaptcha` 校验图形验证码后按手机号每日限 10 次
+  (Redis `yoshop:api:sms:send:{mobile}`),验证码写入 `yoshop:api:sms:{mobile}` → `{"code","times"}`;
+  **短信通道未接入**,验证码只落缓存并在日志里打印 `[sms] ... code=xxxxxx`,本地联调可看日志或直接读 Redis
+- 收银台:`/api/cashier/orderInfo`(订单 + 用户 + 该客户端可用支付方式)、`/api/cashier/orderPay`、
+  `/api/cashier/tradeQuery`;**余额支付已完整实现**(扣余额、写明细、置订单已付款、累计销量)
+- 支付回调:`/api/notify/wechatV2`(XML)、`/api/notify/wechatV3`(JSON)、`/api/notify/alipay`(表单);
+  交易记录查找与支付成功分发(商城订单 / 余额充值)已实现,**验签未接入**:
+  库内没有支付模板(未配置商户)时跳过验签直接处理,一旦存在支付模板即拒绝回调
 - 出参:驼峰,沿用 store / admin 口径;列表分页结构 `{total, perPage, currentPage, lastPage, data}`
 - 一键验证:
   - `python tools/test_api_batch1.py`(首页 / 店铺 / 地区 / 文章 / 商品 / 营销,52 项)
   - `python tools/test_api_batch2.py`(购物车 / 结算下单 / 订单 / 评价 / 地址,97 项)
   - `python tools/test_api_batch3.py`(用户中心 / 优惠券 / 明细 / 售后 / 充值 / 上传,99 项)
-- 已实现的骨架:第三方支付(微信 / 支付宝下单、交易查询)与短信发送,未配置时返回明确文案;
-  充值支付成功结算 `ApiRechargeService.paySuccess` 已就绪,下一批支付回调直接复用
+  - `python tools/test_api_batch4.py`(验证码 / 登录 / 收银台 / 支付回调,77 项)
+- 仍为骨架的部分:第三方支付下单与查询(微信 / 支付宝商户参数未配置)、支付回调验签、
+  短信发送通道、微信头像外链下载;未配置时均返回明确文案,不会静默失败