Jelajahi Sumber

新增客户端 api 模块第三批接口(18 个),用户中心与充值售后链路打通

- 用户中心:user/info(头像+会员等级+手机号中间四位打码)、user/assets(余额/积分/可用券)、
  user/bindMobile(短信验证码校验 + 手机号唯一)、user/personal(头像昵称)
- 我的优惠券:myCoupon/list(all/isUsable/isUnused/isExpire/isUse + amount 门槛)、myCoupon/receive
  (写领取记录并累计 receive_num)
- 明细:balance.log/list、points.log/list(分页 15、时间倒序、隐藏 storeId 与未加载的 user)
- 售后:refund/list、refund/goods、refund/apply、refund/detail、refund/delivery
  列表按状态筛选并追加 stateText;详情装配凭证图片、退货地址(含省市区名称)、用户发货物流
- 充值:recharge/center、recharge/submit、recharge/tradeQuery、recharge.order/list
  设置缺省用 PHP 默认值兜底并转驼峰;支付方式按客户端过滤并排除余额支付;
  自定义金额支持自动匹配套餐;paySuccess 实现充值到账结算(幂等,供支付回调复用)
- 上传:upload/image,UploadService 增补 uploadImageByUser(channel=20、记录 uploaderId)
- 新增 ApiSmsService:短信验证码存 Redis(yoshop:api:sms:{mobile}),错误减次数、通过即删;
  发送通道仍为骨架,未配置时返回明确文案
- 新增验证脚本 tools/test_api_batch3.py(99 项断言全过,跑完自动清理并恢复用户资料)
- 新增文档 接口实现进度-第12批-api.md,联调说明补充短信验证码与三批验证脚本
tanlie 7 jam lalu
induk
melakukan
9928a2160d

+ 559 - 0
tools/test_api_batch3.py

@@ -0,0 +1,559 @@
+# -*- coding: utf-8 -*-
+"""客户端 api 第 3 批接口冒烟:用户中心 / 我的优惠券 / 余额积分明细 / 售后 / 充值 / 上传。
+
+用法:起 Java 18080 后执行
+    python tools/test_api_batch3.py
+环境变量:YOSHOP_API_BASE(默认 http://127.0.0.1:18080)
+
+说明:
+- 全部是需登录接口,脚本会往 Redis 写测试 token(yoshop:api:token:apitest-batch3)与短信验证码;
+- 会临时造订单、售后单、充值订单、支付方式、优惠券领取记录、上传文件、临时用户,
+  finally 中全部清理并把用户资料(手机号 / 昵称 / 头像)恢复原值。
+"""
+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-batch3"
+USER_ID = 10001
+GOODS_ID = 10009
+SKU_ID = "10009"
+# 领取测试用券(满 100 减 10、领取后 7 天有效)
+COUPON_ID = 10001
+PLAN_ID = 10001
+EXPRESS_ID = 10007
+
+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_refunds = []
+created_recharge_orders = []
+created_files = []
+temp_user_id = None
+user_backup = {}
+coupon_receive_backup = 0
+
+# 1x1 PNG
+PNG = bytes.fromhex(
+    "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
+    "0000000d4944415408d76368f8ffff3f0005fe02fea735e1f90000000049454e44ae426082"
+)
+
+
+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, files=None, data=None):
+    headers = {"platform": "MP-WEIXIN"}
+    if token:
+        headers["Access-Token"] = token
+    if files is not None:
+        resp = S.post(BASE + path, params=params or {}, files=files, data=data or {},
+                      headers=headers, timeout=60)
+    elif 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 upload(path, token=TOKEN, check_login=1, name="test.png"):
+    files = {"file": (name, PNG, "image/png")}
+    return call("POST", path, token=token, files=files, data={"checkLogin": str(check_login)})
+
+
+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_set(key, value):
+    sock = socket.create_connection(("192.168.1.177", 6379), timeout=5)
+    try:
+        cmd(sock, "AUTH", REDIS_PWD)
+        cmd(sock, "SET", key, str(value))
+        cmd(sock, "EXPIRE", key, "3600")
+    finally:
+        sock.close()
+
+
+def field(obj, name, default=None):
+    return obj.get(name, default) if isinstance(obj, dict) else default
+
+
+def make_order():
+    """造一条已完成订单 + 订单商品,返回 (order_id, order_goods_id)"""
+    order_no = "TESTB3" + 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, 80.00, 0.00, '', '', 20, 0, "
+        f"10, 0.00, 20, 20, 30, 0, 1, 1, 10, 0, '[]', 'H5', {USER_ID}, 0, 10001, "
+        "UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
+    created_orders.append(order_id)
+    goods_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, 20, 1, 1, {order_id}, {USER_ID}, 0, "
+        "10001, UNIX_TIMESTAMP())")
+    return order_id, goods_id
+
+
+def make_payment(client, method, is_default=1):
+    return db_exec(
+        "INSERT INTO yoshop_payment (client, method, is_must_template, template_id, is_enable, is_default, "
+        f"others, store_id, create_time, update_time) VALUES ('{client}', '{method}', 0, 0, 1, {is_default}, "
+        "'[]', 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
+
+
+def cleanup():
+    try:
+        for order_id in created_orders:
+            db_exec(f"DELETE FROM yoshop_order_goods WHERE order_id={order_id}")
+            db_exec(f"DELETE FROM yoshop_order WHERE order_id={order_id}")
+        for refund_id in created_refunds:
+            db_exec(f"DELETE FROM yoshop_order_refund_image WHERE order_refund_id={refund_id}")
+            db_exec(f"DELETE FROM yoshop_order_refund_address WHERE order_refund_id={refund_id}")
+            db_exec(f"DELETE FROM yoshop_order_refund WHERE order_refund_id={refund_id}")
+        for order_id in created_recharge_orders:
+            db_exec(f"DELETE FROM yoshop_recharge_order_plan WHERE order_id={order_id}")
+            db_exec(f"DELETE FROM yoshop_recharge_order WHERE order_id={order_id}")
+        db_exec("DELETE FROM yoshop_payment WHERE store_id=10001 AND method IN ('wechat','balance') "
+                "AND client='MP-WEIXIN'")
+        db_exec(f"DELETE FROM yoshop_user_coupon WHERE user_id={USER_ID} AND coupon_id={COUPON_ID}")
+        db_exec(f"UPDATE yoshop_coupon SET receive_num={coupon_receive_backup} WHERE coupon_id={COUPON_ID}")
+        if temp_user_id:
+            db_exec(f"DELETE FROM yoshop_user WHERE user_id={temp_user_id}")
+        if user_backup:
+            db_exec("UPDATE yoshop_user SET mobile='{}', nick_name='{}', avatar_id={} WHERE user_id={}".format(
+                user_backup["mobile"], user_backup["nick_name"], user_backup["avatar_id"], USER_ID))
+        for file_id in created_files:
+            db_exec(f"DELETE FROM yoshop_upload_file WHERE file_id={file_id}")
+        print("  CLEAN 测试数据已清理")
+    except Exception as e:  # noqa: BLE001
+        print("  CLEAN 失败:", e)
+
+
+def main():
+    global temp_user_id, coupon_receive_backup
+    print("== 客户端 api 第 3 批(用户中心 / 优惠券 / 明细 / 售后 / 充值 / 上传) ==")
+    redis_set("yoshop:api:token:" + TOKEN, USER_ID)
+    row = db(f"SELECT mobile, nick_name, avatar_id FROM yoshop_user WHERE user_id={USER_ID}")[0]
+    user_backup.update(mobile=row[0], nick_name=row[1], avatar_id=row[2])
+    coupon_receive_backup = db(f"SELECT receive_num FROM yoshop_coupon WHERE coupon_id={COUPON_ID}")[0][0]
+
+    # ---------------------------------------------------------------- 未登录
+    print("-- 未登录校验")
+    for path, label in [("/api/user/info", "user/info"), ("/api/user/assets", "user/assets"),
+                        ("/api/myCoupon/list", "myCoupon/list"), ("/api/refund/list", "refund/list")]:
+        r = get(path, token=None)
+        check(f"未登录 {label} 返回 401", r.get("status") == 401, r.get("message"))
+
+    # ---------------------------------------------------------------- 用户信息
+    print("-- 用户中心")
+    r = get("/api/user/info")
+    info = field(r.get("data"), "userInfo") or {}
+    check("user/info status=200", r.get("status") == 200, r.get("message"))
+    check("user/info 手机号打码", info.get("mobile") == "185****1340", info.get("mobile"))
+    check("user/info 含 avatarUrl / grade / balance 键",
+          all(k in info for k in ("avatarUrl", "grade", "balance", "points")), list(info.keys()))
+    check("user/info 隐藏 isDelete/storeId/createTime",
+          not any(k in info for k in ("isDelete", "storeId", "createTime", "updateTime", "password")),
+          [k for k in ("isDelete", "storeId", "createTime") if k in info])
+    check("user/info 无 snake_case", not snake_keys(info), snake_keys(info)[:3])
+
+    r = get("/api/user/assets")
+    assets = field(r.get("data"), "assets") or {}
+    check("user/assets 三个字段齐全", set(assets.keys()) == {"balance", "points", "coupon"}, assets.keys())
+    check("user/assets balance 两位小数字符串",
+          isinstance(assets.get("balance"), str) and re.fullmatch(r"-?\d+\.\d{2}", assets.get("balance", "")),
+          assets.get("balance"))
+    check("user/assets 无 snake_case", not snake_keys(assets), snake_keys(assets)[:3])
+
+    # ---------------------------------------------------------------- 明细
+    print("-- 余额 / 积分明细")
+    r = get("/api/balance.log/list")
+    logs = field(r.get("data"), "data") or []
+    check("balance.log/list status=200", r.get("status") == 200, r.get("message"))
+    check("balance.log/list 有数据且按时间倒序",
+          len(logs) > 0 and (len(logs) < 2 or logs[0]["createTime"] >= logs[1]["createTime"]), len(logs))
+    check("balance.log/list 隐藏 storeId 与 user",
+          all("storeId" not in x and "user" not in x for x in logs[:3]), logs[:1])
+    check("balance.log/list perPage=15", field(r.get("data"), "perPage") == 15, field(r.get("data"), "perPage"))
+
+    r = get("/api/points.log/list")
+    plogs = field(r.get("data"), "data") or []
+    check("points.log/list status=200", r.get("status") == 200, r.get("message"))
+    check("points.log/list 字段为 logId/value/describe",
+          all({"logId", "value", "describe"} <= set(x.keys()) for x in plogs[:3]), plogs[:1])
+    check("points.log/list 无 snake_case", not snake_keys(plogs), snake_keys(plogs)[:3])
+
+    # ---------------------------------------------------------------- 修改资料
+    print("-- 修改资料")
+    r = post("/api/user/personal", {"form": {"nickName": "接口自测昵称"}})
+    check("user/personal 修改成功", r.get("status") == 200 and r.get("message") == "恭喜您,信息修改成功", r)
+    nick = db(f"SELECT nick_name, avatar_id FROM yoshop_user WHERE user_id={USER_ID}")[0]
+    check("user/personal 昵称落库", nick[0] == "接口自测昵称", nick)
+    check("user/personal 头像沿用原值", nick[1] == user_backup["avatar_id"], nick[1])
+
+    # ---------------------------------------------------------------- 绑定手机号
+    print("-- 绑定手机号")
+    new_mobile = "19900002222"
+    r = post("/api/user/bindMobile", {"form": {"mobile": new_mobile, "smsCode": "123456"}})
+    check("bindMobile 无验证码时报错", r.get("status") == 500 and "短信验证码不存在" in (r.get("message") or ""), r)
+
+    redis_set("yoshop:api:sms:" + new_mobile, json.dumps({"code": "654321", "times": 5}))
+    r = post("/api/user/bindMobile", {"form": {"mobile": new_mobile, "smsCode": "000000"}})
+    check("bindMobile 验证码错误时报错", r.get("status") == 500 and "验证码不正确" in (r.get("message") or ""), r)
+    left = json.loads(db_redis("yoshop:api:sms:" + new_mobile) or "null")
+    check("bindMobile 错误一次后剩余次数减一", left and left.get("times") == 4, left)
+
+    r = post("/api/user/bindMobile", {"form": {"mobile": new_mobile, "smsCode": "654321"}})
+    check("bindMobile 绑定成功", r.get("status") == 200 and r.get("message") == "恭喜您,手机号绑定成功", r)
+    check("bindMobile 手机号落库", db(f"SELECT mobile FROM yoshop_user WHERE user_id={USER_ID}")[0][0] == new_mobile)
+    check("bindMobile 验证码用后即焚", db_redis("yoshop:api:sms:" + new_mobile) is None)
+
+    # 手机号已被占用
+    temp_user_id = db_exec(
+        "INSERT INTO yoshop_user (mobile, nick_name, avatar_id, gender, country, province, city, address_id, "
+        "balance, points, pay_money, expend_money, grade_id, platform, last_login_time, is_delete, store_id, "
+        "create_time, update_time) VALUES ('19900003333', '临时用户', 0, 0, '', '', '', 0, 0.00, 0, 0.00, "
+        "0.00, 0, 'H5', 0, 0, 10001, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
+    redis_set("yoshop:api:sms:19900003333", json.dumps({"code": "111111", "times": 5}))
+    r = post("/api/user/bindMobile", {"form": {"mobile": "19900003333", "smsCode": "111111"}})
+    check("bindMobile 手机号已占用时报错",
+          r.get("status") == 500 and "已绑定其他账户" in (r.get("message") or ""), r)
+
+    # ---------------------------------------------------------------- 我的优惠券
+    print("-- 我的优惠券")
+    r = post("/api/myCoupon/receive", {"couponId": 999999})
+    check("myCoupon/receive 券不存在时报错",
+          r.get("status") == 500 and "优惠券不存在" in (r.get("message") or ""), r)
+
+    r = post("/api/myCoupon/receive", {"couponId": COUPON_ID})
+    check("myCoupon/receive 领取成功", r.get("status") == 200 and r.get("message") == "领取成功", r)
+    recv = db(f"SELECT user_coupon_id, name, is_use, is_expire, start_time, end_time "
+              f"FROM yoshop_user_coupon WHERE user_id={USER_ID} AND coupon_id={COUPON_ID}")
+    check("领取记录落库且未使用未过期", len(recv) == 1 and recv[0][2] == 0 and recv[0][3] == 0, recv)
+    check("领取后有效期为 7 天", len(recv) == 1 and recv[0][5] - recv[0][4] == 7 * 86400, recv)
+    check("券已领取数量 +1",
+          db(f"SELECT receive_num FROM yoshop_coupon WHERE coupon_id={COUPON_ID}")[0][0]
+          == coupon_receive_backup + 1)
+
+    r = post("/api/myCoupon/receive", {"couponId": COUPON_ID})
+    check("myCoupon/receive 重复领取被拒",
+          r.get("status") == 500 and "已领取该优惠券" in (r.get("message") or ""), r)
+
+    r = get("/api/myCoupon/list")
+    data = field(r.get("data"), "data") or []
+    check("myCoupon/list 含刚领取的券", any(x.get("couponId") == COUPON_ID for x in data), len(data))
+    check("myCoupon/list 隐藏 storeId/createTime/updateTime",
+          all(not {"storeId", "createTime", "updateTime"} & set(x.keys()) for x in data), data[:1])
+    check("myCoupon/list state 为正常", data and field(data[0].get("state"), "text") == "正常", data[:1])
+    check("myCoupon/list discount 为 0-10 小数", all(isinstance(x.get("discount"), (int, float)) for x in data),
+          [x.get("discount") for x in data[:2]])
+
+    r = get("/api/myCoupon/list", {"dataType": "isUsable"})
+    usable = field(r.get("data"), "data") or []
+    check("myCoupon/list isUsable 含该券", any(x.get("couponId") == COUPON_ID for x in usable), len(usable))
+
+    r = get("/api/myCoupon/list", {"dataType": "isUsable", "amount": 50})
+    filtered = field(r.get("data"), "data") or []
+    check("myCoupon/list amount=50 过滤掉满 100 的券",
+          not any(x.get("couponId") == COUPON_ID for x in filtered), len(filtered))
+
+    r = get("/api/user/assets")
+    check("user/assets 可用券数量 +1", field(field(r.get("data"), "assets"), "coupon") == 1,
+          field(field(r.get("data"), "assets"), "coupon"))
+
+    # ---------------------------------------------------------------- 售后
+    print("-- 售后服务")
+    order_id, order_goods_id = make_order()
+    r = get("/api/refund/goods", {"orderGoodsId": order_goods_id})
+    goods = field(r.get("data"), "goods") or {}
+    check("refund/goods status=200", r.get("status") == 200, r.get("message"))
+    check("refund/goods 含商品图与金额", "goodsImage" in goods and goods.get("totalPayPrice") == "80.00",
+          {k: goods.get(k) for k in ("goodsImage", "totalPayPrice")})
+    check("refund/goods 无 snake_case", not snake_keys(goods), snake_keys(goods)[:3])
+
+    r = get("/api/refund/goods", {"orderGoodsId": 99999999})
+    check("refund/goods 不存在时报错", r.get("status") == 500 and "未找到订单商品" in (r.get("message") or ""), r)
+
+    r = post("/api/refund/apply", {"orderGoodsId": order_goods_id, "form": {"content": "不想要了"}})
+    check("refund/apply 缺少类型时报错", r.get("status") == 500 and "请选择售后类型" in (r.get("message") or ""), r)
+
+    r = post("/api/refund/apply", {"orderGoodsId": order_goods_id,
+                                   "form": {"type": 10, "content": "接口自测:商品有瑕疵"}})
+    check("refund/apply 提交成功", r.get("status") == 200 and r.get("message") == "提交成功", r)
+    refund_rows = db(f"SELECT order_refund_id, type, apply_desc, audit_status, status, is_user_send "
+                     f"FROM yoshop_order_refund WHERE order_goods_id={order_goods_id}")
+    check("售后单落库为待审核", len(refund_rows) == 1 and refund_rows[0][3] == 0 and refund_rows[0][4] == 0,
+          refund_rows)
+    refund_id = refund_rows[0][0]
+    created_refunds.append(refund_id)
+
+    r = get("/api/refund/goods", {"orderGoodsId": order_goods_id})
+    check("已申请售后不可重复申请", r.get("status") == 500 and "已申请售后" in (r.get("message") or ""), r)
+
+    r = get("/api/refund/detail", {"orderRefundId": refund_id})
+    detail = field(r.get("data"), "detail") or {}
+    check("refund/detail stateText=等待审核中", detail.get("stateText") == "等待审核中", detail.get("stateText"))
+    check("refund/detail 含 orderGoods 且图片为空数组",
+          isinstance(detail.get("orderGoods"), dict) and detail.get("images") == [], detail.get("images"))
+    check("refund/detail 隐藏 storeId/updateTime",
+          not {"storeId", "updateTime"} & set(detail.keys()), [k for k in ("storeId", "updateTime") if k in detail])
+    check("refund/detail 无 snake_case", not snake_keys(detail), snake_keys(detail)[:3])
+
+    r = get("/api/refund/list", {"state": 0})
+    refund_list = field(r.get("data"), "data") or []
+    check("refund/list 含该售后单", any(x.get("orderRefundId") == refund_id for x in refund_list), len(refund_list))
+    check("refund/list 每项均带 stateText", all(x.get("stateText") for x in refund_list), refund_list[:1])
+    check("refund/list 隐藏 storeId/updateTime/images",
+          all(not {"storeId", "updateTime", "images"} & set(x.keys()) for x in refund_list), refund_list[:1])
+
+    r = get("/api/refund/list", {"state": 20})
+    check("refund/list state=20 不含进行中的单",
+          not any(x.get("orderRefundId") == refund_id for x in (field(r.get("data"), "data") or [])), r.get("data"))
+
+    # 未审核不允许发货
+    r = post("/api/refund/delivery", {"orderRefundId": refund_id,
+                                      "form": {"expressId": EXPRESS_ID, "expressNo": "SF0001"}})
+    check("refund/delivery 未审核时报错", r.get("status") == 500 and "不合法" in (r.get("message") or ""), r)
+
+    db_exec(f"UPDATE yoshop_order_refund SET audit_status=10 WHERE order_refund_id={refund_id}")
+    r = post("/api/refund/delivery", {"orderRefundId": refund_id, "form": {"expressNo": "SF0001"}})
+    check("refund/delivery 未选物流公司时报错",
+          r.get("status") == 500 and "请选择物流公司" in (r.get("message") or ""), r)
+    r = post("/api/refund/delivery", {"orderRefundId": refund_id, "form": {"expressId": EXPRESS_ID}})
+    check("refund/delivery 未填单号时报错",
+          r.get("status") == 500 and "请填写物流单号" in (r.get("message") or ""), r)
+
+    r = post("/api/refund/delivery", {"orderRefundId": refund_id,
+                                      "form": {"expressId": EXPRESS_ID, "expressNo": "SF0001"}})
+    check("refund/delivery 发货成功", r.get("status") == 200 and r.get("message") == "操作成功", r)
+    sent = db(f"SELECT is_user_send, express_id, express_no, send_time FROM yoshop_order_refund "
+              f"WHERE order_refund_id={refund_id}")[0]
+    check("售后单发货信息落库",
+          sent[0] == 1 and str(sent[1]) == str(EXPRESS_ID) and sent[2] == "SF0001" and sent[3] > 0, sent)
+
+    r = get("/api/refund/detail", {"orderRefundId": refund_id})
+    detail = field(r.get("data"), "detail") or {}
+    check("refund/detail 发货后 stateText 变化",
+          detail.get("stateText") == "已发货,待平台确认", detail.get("stateText"))
+    check("refund/detail 带物流公司名称",
+          field(detail.get("express"), "expressName") == "中通快递", detail.get("express"))
+
+    r = get("/api/refund/detail", {"orderRefundId": 99999999})
+    check("refund/detail 不存在时报错", r.get("status") == 500 and "未找到该售后单" in (r.get("message") or ""), r)
+
+    # ---------------------------------------------------------------- 充值
+    print("-- 余额充值")
+    r = get("/api/recharge/center", {"client": "MP-WEIXIN"})
+    check("recharge/center 无支付方式时报错",
+          r.get("status") == 500 and "没有可用的支付方式" in (r.get("message") or ""), r)
+
+    make_payment("MP-WEIXIN", "wechat", 1)
+    make_payment("MP-WEIXIN", "balance", 0)
+    r = get("/api/recharge/center", {"client": "MP-WEIXIN"})
+    center = r.get("data") or {}
+    setting = field(center, "setting") or {}
+    check("recharge/center status=200", r.get("status") == 200, r.get("message"))
+    check("recharge/center 设置默认值(isCustom=1 / lowestMoney=10 / isMatchPlan=1)",
+          setting.get("isCustom") == 1 and float(setting.get("lowestMoney")) == 10.0
+          and setting.get("isMatchPlan") == 1, setting)
+    check("recharge/center personal 为当前用户",
+          field(field(center, "personal"), "userId") == USER_ID, field(center, "personal"))
+    check("recharge/center planList 含套餐", any(p.get("planId") == PLAN_ID for p in
+                                                (field(center, "planList") or [])), field(center, "planList"))
+    check("recharge/center paymentMethods 排除余额支付",
+          [m.get("method") for m in (field(center, "paymentMethods") or [])] == ["wechat"],
+          field(center, "paymentMethods"))
+    check("recharge/center 无 snake_case", not snake_keys(center), snake_keys(center)[:3])
+
+    r = post("/api/recharge/submit", {"form": {"method": "balance", "client": "MP-WEIXIN"}})
+    check("recharge/submit 缺套餐与金额时报错",
+          r.get("status") == 500 and "请选择充值套餐或输入充值金额" in (r.get("message") or ""), r)
+
+    r = post("/api/recharge/submit", {"form": {"method": "balance", "client": "MP-WEIXIN",
+                                               "customMoney": "5"}})
+    check("recharge/submit 低于最低金额时报错",
+          r.get("status") == 500 and "最低充值金额不能低于10元" in (r.get("message") or ""), r)
+
+    r = post("/api/recharge/submit", {"form": {"method": "balance", "client": "MP-WEIXIN",
+                                               "customMoney": "100"}})
+    check("recharge/submit 自定义金额下单成功",
+          r.get("status") == 200 and r.get("message") == "下单成功" and field(r.get("data"), "payment") == [], r)
+    row = db(f"SELECT order_id, order_no, recharge_type, plan_id, pay_price, gift_money, actual_money, "
+             f"pay_status, platform FROM yoshop_recharge_order WHERE user_id={USER_ID} ORDER BY order_id DESC "
+             "LIMIT 1")[0]
+    created_recharge_orders.append(row[0])
+    check("充值订单号以 RC 开头且 18 位以上", str(row[1]).startswith("RC") and len(str(row[1])) >= 18, row[1])
+    check("自定义充值匹配套餐(赠 10)",
+          row[2] == 10 and row[3] == PLAN_ID and float(row[5]) == 10.0 and float(row[6]) == 110.0, row)
+    check("充值订单平台取请求头", row[8] == "MP-WEIXIN", row[8])
+    snapshot = db(f"SELECT plan_id, plan_name, money, gift_money FROM yoshop_recharge_order_plan "
+                  f"WHERE order_id={row[0]}")
+    check("套餐快照落库", len(snapshot) == 1 and snapshot[0][0] == PLAN_ID, snapshot)
+
+    r = post("/api/recharge/submit", {"form": {"method": "balance", "client": "MP-WEIXIN",
+                                               "planId": PLAN_ID}})
+    check("recharge/submit 指定套餐下单成功", r.get("status") == 200, r)
+    row2 = db(f"SELECT order_id, recharge_type, plan_id, pay_price, gift_money, actual_money "
+              f"FROM yoshop_recharge_order WHERE user_id={USER_ID} ORDER BY order_id DESC LIMIT 1")[0]
+    created_recharge_orders.append(row2[0])
+    check("套餐充值金额与赠送正确",
+          row2[1] == 20 and float(row2[3]) == 100.0 and float(row2[4]) == 10.0 and float(row2[5]) == 110.0, row2)
+
+    r = post("/api/recharge/submit", {"form": {"method": "wechat", "client": "MP-WEIXIN",
+                                               "planId": PLAN_ID}})
+    check("recharge/submit 微信支付未接入时明确报错",
+          r.get("status") == 500 and "第三方支付" in (r.get("message") or ""), r)
+
+    r = get("/api/recharge/tradeQuery", {"outTradeNo": "RC123456", "method": "wechat",
+                                         "client": "MP-WEIXIN"})
+    check("recharge/tradeQuery 未支付返回 isPay=false",
+          r.get("status") == 200 and field(r.get("data"), "isPay") is False, r)
+    check("recharge/tradeQuery 提示未支付",
+          "订单未支付" in (r.get("message") or ""), r.get("message"))
+
+    db_exec(f"UPDATE yoshop_recharge_order SET pay_status=20, pay_time=UNIX_TIMESTAMP(), pay_method='balance' "
+            f"WHERE order_id={row[0]}")
+    r = get("/api/recharge.order/list")
+    orders = field(r.get("data"), "data") or []
+    check("recharge.order/list 只列已支付订单",
+          [o.get("orderId") for o in orders] == [row[0]], [o.get("orderId") for o in orders])
+    check("recharge.order/list 隐藏 tradeId/createTime/storeId",
+          all(not {"tradeId", "createTime", "storeId"} & set(o.keys()) for o in orders), orders[:1])
+    check("recharge.order/list 金额为两位小数字符串",
+          orders and orders[0].get("actualMoney") == "110.00", orders[:1])
+    check("recharge.order/list 无 snake_case", not snake_keys(orders), snake_keys(orders)[:3])
+
+    # ---------------------------------------------------------------- 上传
+    print("-- 文件上传")
+    r = upload("/api/upload/image", token=None, check_login=1)
+    check("upload/image 需要登录时未登录返回 401", r.get("status") == 401, r)
+
+    r = upload("/api/upload/image", check_login=1)
+    file_info = field(r.get("data"), "fileInfo") or {}
+    check("upload/image 上传成功", r.get("status") == 200 and r.get("message") == "文件上传成功", r)
+    check("upload/image 返回 fileId 与 previewUrl",
+          isinstance(file_info.get("fileId"), int) and str(file_info.get("previewUrl", "")).startswith("http"),
+          file_info)
+    check("upload/image 隐藏 filePath/fileName/storeId",
+          not {"filePath", "fileName", "storeId", "uploaderId"} & set(file_info.keys()), list(file_info.keys()))
+    if file_info.get("fileId"):
+        created_files.append(file_info["fileId"])
+        uploader = db(f"SELECT channel, uploader_id, file_type, is_delete FROM yoshop_upload_file "
+                      f"WHERE file_id={file_info['fileId']}")[0]
+        check("上传记录 channel=20 且记录上传者", uploader[0] == 20 and uploader[1] == USER_ID, uploader)
+
+    r = upload("/api/upload/image", token=None, check_login=0)
+    anon_info = field(r.get("data"), "fileInfo") or {}
+    check("upload/image checkLogin=0 允许匿名上传", r.get("status") == 200, r)
+    if anon_info.get("fileId"):
+        created_files.append(anon_info["fileId"])
+        check("匿名上传 uploader_id=0",
+              db(f"SELECT uploader_id FROM yoshop_upload_file WHERE file_id={anon_info['fileId']}")[0][0] == 0)
+
+    # 用上传的文件改头像
+    if file_info.get("fileId"):
+        r = post("/api/user/personal", {"form": {"avatarId": file_info["fileId"]}})
+        check("user/personal 设置头像成功", r.get("status") == 200, r)
+        r = get("/api/user/info")
+        avatar_url = field(field(r.get("data"), "userInfo"), "avatarUrl")
+        check("user/info 头像地址随之上线", isinstance(avatar_url, str) and avatar_url.startswith("http"),
+              avatar_url)
+        check("user/info avatar 对象非空", field(field(r.get("data"), "userInfo"), "avatar") is not None)
+
+    print(f"\n结果:PASS {passed} / FAIL {failed}")
+    return 1 if failed else 0
+
+
+def db_redis(key):
+    sock = socket.create_connection(("192.168.1.177", 6379), timeout=5)
+    try:
+        cmd(sock, "AUTH", REDIS_PWD)
+        result = cmd(sock, "GET", key)
+    finally:
+        sock.close()
+    return result if isinstance(result, str) else None
+
+
+if __name__ == "__main__":
+    try:
+        code = main()
+    finally:
+        cleanup()
+    sys.exit(code)

+ 28 - 1
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiMarketingController.java

@@ -2,11 +2,15 @@ package cn.yoshop.api.controller;
 
 import cn.yoshop.api.service.ApiCommentService;
 import cn.yoshop.api.service.ApiCouponService;
+import cn.yoshop.api.service.ApiUserCouponService;
 import cn.yoshop.api.support.ApiContext;
 import cn.yoshop.common.api.R;
 import cn.yoshop.common.entity.Express;
+import cn.yoshop.common.entity.User;
 import cn.yoshop.common.exception.BizException;
 import cn.yoshop.common.mapper.ExpressMapper;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
 
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -23,7 +27,8 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * 客户端评价、优惠券、物流公司,对齐 PHP app/api/controller/{Comment, Coupon, Express}。
+ * 客户端评价、优惠券(优惠券中心 / 我的优惠券)、物流公司,
+ * 对齐 PHP app/api/controller/{Comment, Coupon, MyCoupon, Express}。
  */
 @RestController
 @RequestMapping("/api")
@@ -32,6 +37,7 @@ public class ApiMarketingController {
 
     private final ApiCommentService apiCommentService;
     private final ApiCouponService apiCouponService;
+    private final ApiUserCouponService apiUserCouponService;
     private final ExpressMapper expressMapper;
     private final cn.yoshop.api.support.ApiView view;
 
@@ -76,6 +82,27 @@ public class ApiMarketingController {
         return R.ok(data);
     }
 
+    /** /api/myCoupon/list 我的优惠券(dataType:all/isUsable/isUnused/isExpire/isUse,amount 为订单金额) */
+    @RequestMapping(value = "/myCoupon/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<PageVO<ObjectNode>> myCouponList(@RequestBody(required = false) Map<String, Object> body,
+                                             HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        Map<String, Object> params = ApiContext.params(request, body);
+        return R.ok(apiUserCouponService.list(user,
+                Params.strOrNull(params, "dataType"),
+                ApiUserCouponService.amount(params),
+                Params.intVal(params, "page", 1)));
+    }
+
+    /** /api/myCoupon/receive 领取优惠券 */
+    @RequestMapping(value = "/myCoupon/receive", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> myCouponReceive(@RequestBody(required = false) Map<String, Object> body,
+                                   HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        Map<String, Object> params = ApiContext.params(request, body);
+        return R.okMsg(apiUserCouponService.receive(user, requiredInt(params, "couponId")));
+    }
+
     /** /api/express/list 物流公司(隐藏 kuaidi100Code / kdniaoCode / sort) */
     @RequestMapping(value = "/express/list", method = {RequestMethod.GET, RequestMethod.POST})
     public R<Map<String, Object>> expressList() {

+ 68 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiRechargeController.java

@@ -0,0 +1,68 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiRechargeService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+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/{Recharge, recharge/Order}。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiRechargeController {
+
+    private final ApiRechargeService apiRechargeService;
+
+    /** /api/recharge/center 充值中心(client 为当前客户端) */
+    @RequestMapping(value = "/recharge/center", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> center(@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(apiRechargeService.center(user, Params.str(params, "client", "")));
+    }
+
+    /** /api/recharge/submit 确认充值订单(表单:method / client / planId / customMoney / extra) */
+    @RequestMapping(value = "/recharge/submit", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> submit(@RequestBody(required = false) Map<String, Object> body,
+                                         HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        Map<String, Object> params = Params.merge(body, request);
+        Map<String, Object> data = apiRechargeService.submit(user, params, ApiContext.platform(request));
+        return R.ok(data, "下单成功");
+    }
+
+    /** /api/recharge/tradeQuery 交易查询(outTradeNo / method / client) */
+    @RequestMapping(value = "/recharge/tradeQuery", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> tradeQuery(@RequestBody(required = false) Map<String, Object> body,
+                                             HttpServletRequest request) {
+        Map<String, Object> params = Params.merge(body, request);
+        Map<String, Object> data = apiRechargeService.tradeQuery(
+                Params.str(params, "outTradeNo", ""), Params.str(params, "method", ""));
+        boolean isPay = Boolean.TRUE.equals(data.get("isPay"));
+        return R.ok(data, isPay ? "恭喜您,余额充值成功" : "很抱歉,订单未支付,请重新发起");
+    }
+
+    /** /api/recharge.order/list 我的充值记录(分页 15) */
+    @RequestMapping(value = "/recharge.order/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<PageVO<ObjectNode>> orderList(@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(apiRechargeService.orderList(user, Params.intVal(params, "page", 1)));
+    }
+}

+ 85 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiRefundController.java

@@ -0,0 +1,85 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiRefundService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+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/Refund。
+ */
+@RestController
+@RequestMapping("/api/refund")
+@RequiredArgsConstructor
+public class ApiRefundController {
+
+    private final ApiRefundService apiRefundService;
+
+    /** /api/refund/list 售后单列表(state 默认 -1 全部) */
+    @RequestMapping(value = "/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<PageVO<ObjectNode>> list(@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(apiRefundService.list(user,
+                Params.intVal(params, "state", -1),
+                Params.intVal(params, "page", 1)));
+    }
+
+    /** /api/refund/goods 申请售后前的订单商品信息 */
+    @RequestMapping(value = "/goods", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> goods(@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(apiRefundService.goods(user, requiredInt(params, "orderGoodsId")));
+    }
+
+    /** /api/refund/apply 申请售后(表单:type / content / images[]) */
+    @RequestMapping(value = "/apply", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> apply(@RequestBody(required = false) Map<String, Object> body,
+                         HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        Map<String, Object> params = Params.merge(body, request);
+        return R.okMsg(apiRefundService.apply(user, requiredInt(params, "orderGoodsId"), params));
+    }
+
+    /** /api/refund/detail 售后单详情 */
+    @RequestMapping(value = "/detail", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> detail(@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(apiRefundService.detail(user, requiredInt(params, "orderRefundId")));
+    }
+
+    /** /api/refund/delivery 用户发货(表单:expressId / expressNo) */
+    @RequestMapping(value = "/delivery", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> delivery(@RequestBody(required = false) Map<String, Object> body,
+                            HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        Map<String, Object> params = Params.merge(body, request);
+        return R.okMsg(apiRefundService.delivery(user, requiredInt(params, "orderRefundId"), params));
+    }
+
+    private static int requiredInt(Map<String, Object> params, String key) {
+        Integer value = Params.intOrNull(params, key);
+        if (value == null) {
+            throw BizException.silent("参数错误:" + key);
+        }
+        return value;
+    }
+}

+ 55 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiUploadController.java

@@ -0,0 +1,55 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.UploadFile;
+import cn.yoshop.store.service.UploadService;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 客户端文件上传,对齐 PHP app/api/controller/Upload。
+ *
+ * <p>表单字段 {@code file},参数 {@code checkLogin}(默认 1):
+ * 需要登录时上传者记为当前用户,否则记为 0;渠道固定 20(用户端)。
+ */
+@RestController
+@RequestMapping("/api/upload")
+@RequiredArgsConstructor
+public class ApiUploadController {
+
+    /** 客户端上传文件出参隐藏字段(PHP api\model\UploadFile::$hidden) */
+    private static final String[] HIDDEN = {
+            "filePath", "fileName", "groupId", "channel", "storage", "domain", "fileSize", "fileExt",
+            "cover", "uploaderId", "isRecycle", "isDelete", "storeId", "createTime", "updateTime"
+    };
+
+    private final UploadService uploadService;
+    private final ApiView view;
+
+    /** /api/upload/image 图片上传 */
+    @RequestMapping(value = "/image", method = {RequestMethod.POST})
+    public R<Map<String, Object>> image(@RequestParam(value = "file", required = false) MultipartFile file,
+                                        @RequestParam(value = "checkLogin", required = false, defaultValue = "1")
+                                        int checkLogin,
+                                        HttpServletRequest request) {
+        // checkLogin=1 时强制登录(PHP UserService::isLogin(true) 未登录会抛 401)
+        int uploaderId = checkLogin != 0 ? ApiContext.requireUser(request).getUserId() : 0;
+        UploadFile fileInfo = uploadService.uploadImageByUser(file, uploaderId);
+        ObjectNode node = view.hide(view.of(fileInfo), HIDDEN);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("fileInfo", node);
+        return R.ok(data, "文件上传成功");
+    }
+}

+ 79 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiUserController.java

@@ -0,0 +1,79 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiLogService;
+import cn.yoshop.api.service.ApiUserService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+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/{User, balance/Log, points/Log}。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiUserController {
+
+    private final ApiUserService apiUserService;
+    private final ApiLogService apiLogService;
+
+    /** /api/user/info 当前用户详情 */
+    @RequestMapping(value = "/user/info", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> info(HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        return R.ok(apiUserService.info(user));
+    }
+
+    /** /api/user/assets 账户资产(余额 / 积分 / 可用优惠券数量) */
+    @RequestMapping(value = "/user/assets", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> assets(HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        return R.ok(apiUserService.assets(user));
+    }
+
+    /** /api/user/bindMobile 绑定手机号(表单:mobile / smsCode) */
+    @RequestMapping(value = "/user/bindMobile", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> bindMobile(@RequestBody(required = false) Map<String, Object> body,
+                              HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        return R.okMsg(apiUserService.bindMobile(user, Params.merge(body, request)));
+    }
+
+    /** /api/user/personal 修改个人信息(表单:avatarId / nickName) */
+    @RequestMapping(value = "/user/personal", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> personal(@RequestBody(required = false) Map<String, Object> body,
+                            HttpServletRequest request) {
+        User user = ApiContext.requireUser(request);
+        return R.okMsg(apiUserService.personal(user, Params.merge(body, request)));
+    }
+
+    /** /api/balance.log/list 余额账单明细(分页 15) */
+    @RequestMapping(value = "/balance.log/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<PageVO<ObjectNode>> balanceList(@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(apiLogService.balanceList(user, Params.intVal(params, "page", 1)));
+    }
+
+    /** /api/points.log/list 积分明细(分页 15) */
+    @RequestMapping(value = "/points.log/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<PageVO<ObjectNode>> pointsList(@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(apiLogService.pointsList(user, Params.intVal(params, "page", 1)));
+    }
+}

+ 77 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiLogService.java

@@ -0,0 +1,77 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserBalanceLog;
+import cn.yoshop.common.entity.UserPointsLog;
+import cn.yoshop.common.mapper.UserBalanceLogMapper;
+import cn.yoshop.common.mapper.UserPointsLogMapper;
+import cn.yoshop.common.page.PageVO;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 余额 / 积分变动明细,对齐 PHP app/api/{controller,model}/user/{BalanceLog, PointsLog}。
+ *
+ * <p>两者口径一致:只查当前用户、按 create_time 倒序、分页 15;
+ * 出参隐藏 storeId,未加载的 user 关联不输出(PHP 未 with 该关联时同样没有该键)。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiLogService {
+
+    private static final String[] HIDDEN = {"storeId", "user"};
+    private static final int PAGE_SIZE = 15;
+
+    private final UserBalanceLogMapper balanceLogMapper;
+    private final UserPointsLogMapper pointsLogMapper;
+    private final ApiView view;
+
+    /** 余额账单明细(分页 15) */
+    public PageVO<ObjectNode> balanceList(User user, int page) {
+        Page<UserBalanceLog> result = balanceLogMapper.selectPage(
+                new Page<>(page <= 0 ? 1 : page, PAGE_SIZE),
+                Wrappers.<UserBalanceLog>lambdaQuery()
+                        .eq(UserBalanceLog::getUserId, user.getUserId())
+                        .orderByDesc(UserBalanceLog::getCreateTime));
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (UserBalanceLog log : result.getRecords()) {
+            nodes.add(view.hide(view.of(log), HIDDEN));
+        }
+        return toPage(result, nodes);
+    }
+
+    /** 积分明细(分页 15) */
+    public PageVO<ObjectNode> pointsList(User user, int page) {
+        Page<UserPointsLog> result = pointsLogMapper.selectPage(
+                new Page<>(page <= 0 ? 1 : page, PAGE_SIZE),
+                Wrappers.<UserPointsLog>lambdaQuery()
+                        .eq(UserPointsLog::getUserId, user.getUserId())
+                        .orderByDesc(UserPointsLog::getCreateTime));
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (UserPointsLog log : result.getRecords()) {
+            nodes.add(view.hide(view.of(log), HIDDEN));
+        }
+        return toPage(result, nodes);
+    }
+
+    /** 分页元数据沿用 PageVO 的口径(页码超过末页时夹到末页) */
+    private static <T> PageVO<ObjectNode> toPage(Page<T> result, List<ObjectNode> nodes) {
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        long lastPage = result.getPages();
+        long current = result.getCurrent();
+        vo.setCurrentPage(current > lastPage ? (lastPage > 0 ? lastPage : 1) : current);
+        vo.setLastPage(lastPage);
+        vo.setData(nodes);
+        return vo;
+    }
+}

+ 407 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiRechargeService.java

@@ -0,0 +1,407 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.RechargeOrder;
+import cn.yoshop.common.entity.RechargeOrderPlan;
+import cn.yoshop.common.entity.RechargePlan;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserBalanceLog;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.RechargeOrderMapper;
+import cn.yoshop.common.mapper.RechargeOrderPlanMapper;
+import cn.yoshop.common.mapper.RechargePlanMapper;
+import cn.yoshop.common.mapper.UserBalanceLogMapper;
+import cn.yoshop.common.mapper.UserMapper;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Money;
+import cn.yoshop.common.util.OrderNo;
+import cn.yoshop.common.util.Params;
+import cn.yoshop.store.service.PaymentSettingService;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端余额充值,对齐 PHP app/api/service/Recharge、app/api/model/recharge/{Order, Plan}。
+ *
+ * <p>口径:
+ * <ul>
+ *   <li>充值中心:充值设置(库内无该项时用 PHP 默认值)+ 当前用户 + 可用套餐 + 该客户端启用的支付方式;</li>
+ *   <li>下单:planId &gt; 0 → 套餐充值(20),否则自定义金额(10);自定义金额可自动匹配满足条件的套餐;</li>
+ *   <li>到账金额 = 支付金额 + 赠送金额(两位小数字符串);</li>
+ *   <li><b>第三方支付为骨架</b>:微信 / 支付宝下单直接给出明确文案,未接入时不落 PaymentTrade;
+ *       余额等其它方式按 PHP 语义不发起第三方支付(payment 为空数组)。</li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiRechargeService {
+
+    /** 充值订单隐藏字段(PHP api\model\recharge\Order::$hidden) */
+    private static final String[] ORDER_HIDDEN = {"tradeId", "storeId", "createTime", "updateTime", "user", "orderPlan"};
+    /** 充值套餐隐藏字段(PHP api\model\recharge\Plan::$hidden) */
+    private static final String[] PLAN_HIDDEN = {"sort", "isDelete", "storeId", "createTime", "updateTime"};
+
+    private static final int PAGE_SIZE = 15;
+    private static final int RECHARGE_TYPE_CUSTOM = 10;
+    private static final int RECHARGE_TYPE_PLAN = 20;
+    private static final int PAY_STATUS_PENDING = 10;
+    private static final int PAY_STATUS_SUCCESS = 20;
+    /** 余额变动场景:用户充值 */
+    private static final int BALANCE_SCENE_RECHARGE = 10;
+
+    /** 充值设置默认值,对齐 PHP app\common\model\store\Setting::defaultData 的 recharge 项 */
+    private static final int DEFAULT_IS_ENTRANCE = 1;
+    private static final int DEFAULT_IS_CUSTOM = 1;
+    private static final String DEFAULT_LOWEST_MONEY = "10";
+    private static final int DEFAULT_IS_MATCH_PLAN = 1;
+    private static final String DEFAULT_DESCRIBE = "1. 账户充值仅限微信在线方式支付,充值金额实时到账;\n"
+            + "2. 账户充值套餐赠送的金额即时到账;\n"
+            + "3. 账户余额有效期:自充值日起至用完即止;\n"
+            + "4. 若有其它疑问,可拨打客服电话400-000-1234";
+
+    private final RechargeOrderMapper rechargeOrderMapper;
+    private final RechargeOrderPlanMapper rechargeOrderPlanMapper;
+    private final RechargePlanMapper rechargePlanMapper;
+    private final UserMapper userMapper;
+    private final UserBalanceLogMapper balanceLogMapper;
+    private final PaymentSettingService paymentSettingService;
+    private final ApiSettingService apiSettingService;
+    private final ApiUserService apiUserService;
+    private final ApiView view;
+
+    // ------------------------------------------------------------------ 充值中心
+
+    /** 充值中心页面数据 {setting, personal, planList, paymentMethods} */
+    public Map<String, Object> center(User user, String client) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("setting", rechargeSetting());
+        data.put("personal", apiUserService.node(user));
+        data.put("planList", planNodes());
+        data.put("paymentMethods", paymentMethods(client));
+        return data;
+    }
+
+    /**
+     * 充值设置:PHP Setting::getItem 会用默认值兜底,这里同样合并默认值;
+     * 库内可能存 snake_case 键,出参统一为 camelCase。
+     */
+    public Map<String, Object> rechargeSetting() {
+        Object item = apiSettingService.getItem("recharge");
+        Map<?, ?> values = item instanceof Map<?, ?> map ? map : Map.of();
+        Map<String, Object> setting = new LinkedHashMap<>();
+        setting.put("isEntrance", pickInt(values, "isEntrance", "is_entrance", DEFAULT_IS_ENTRANCE));
+        setting.put("isCustom", pickInt(values, "isCustom", "is_custom", DEFAULT_IS_CUSTOM));
+        setting.put("lowestMoney", pickNumber(values, "lowestMoney", "lowest_money", DEFAULT_LOWEST_MONEY));
+        setting.put("isMatchPlan", pickInt(values, "isMatchPlan", "is_match_plan", DEFAULT_IS_MATCH_PLAN));
+        setting.put("describe", pickStr(values, "describe", "describe", DEFAULT_DESCRIBE));
+        return setting;
+    }
+
+    /** 可用的充值套餐(PHP Plan::getList:sort asc → money desc → create_time desc) */
+    private List<ObjectNode> planNodes() {
+        List<RechargePlan> plans = rechargePlanMapper.selectList(Wrappers.<RechargePlan>lambdaQuery()
+                .eq(RechargePlan::getStoreId, ApiContext.STORE_ID)
+                .eq(RechargePlan::getIsDelete, 0)
+                .orderByAsc(RechargePlan::getSort)
+                .orderByDesc(RechargePlan::getMoney)
+                .orderByDesc(RechargePlan::getCreateTime));
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (RechargePlan plan : plans) {
+            ObjectNode node = view.hide(view.of(plan), PLAN_HIDDEN);
+            node.put("money", Money.number2(plan.getMoney()));
+            node.put("giftMoney", Money.number2(plan.getGiftMoney()));
+            nodes.add(node);
+        }
+        return nodes;
+    }
+
+    /** 指定客户端启用的支付方式(余额充值场景排除余额支付,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;
+    }
+
+    // ------------------------------------------------------------------ 下单
+
+    /** 确认充值订单 {payment}(PHP Recharge::submit) */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> submit(User user, Map<String, Object> form, String platform) {
+        String method = Params.str(form, "method", "");
+        String client = Params.str(form, "client", "");
+        Integer planId = Params.intOrNull(form, "planId");
+        String customMoney = Params.strOrNull(form, "customMoney");
+
+        RechargeOrder order = buildOrder(user, planId, customMoney, platform);
+        Map<String, Object> out = new LinkedHashMap<>();
+        // 第三方支付下单(微信 / 支付宝为骨架,其余方式按 PHP 语义不产生支付参数)
+        out.put("payment", unifiedOrder(method, client, order));
+        return out;
+    }
+
+    /** 生成并写入充值订单(PHP recharge\Order::createOrder) */
+    private RechargeOrder buildOrder(User user, Integer planId, String customMoney, String platform) {
+        int rechargeType = planId != null && planId > 0 ? RECHARGE_TYPE_PLAN : RECHARGE_TYPE_CUSTOM;
+        BigDecimal custom = parseDecimal(customMoney);
+        if ((planId == null || planId <= 0) && (custom == null || custom.compareTo(BigDecimal.ZERO) <= 0)) {
+            throw new BizException("请选择充值套餐或输入充值金额");
+        }
+        Map<String, Object> setting = rechargeSetting();
+
+        RechargePlan matched = null;
+        BigDecimal payPrice;
+        if (rechargeType == RECHARGE_TYPE_PLAN) {
+            matched = rechargePlanMapper.selectById(planId);
+            if (matched == null) {
+                throw new BizException("充值套餐不存在");
+            }
+            payPrice = Money.of(matched.getMoney());
+        } else {
+            boolean isCustom = toInt(setting.get("isCustom"), DEFAULT_IS_CUSTOM) != 0;
+            if (!isCustom) {
+                throw new BizException("很抱歉,当前不允许充值自定义金额");
+            }
+            if (custom == null || custom.compareTo(BigDecimal.ZERO) <= 0) {
+                throw new BizException("请输入正确的充值金额");
+            }
+            BigDecimal lowest = parseDecimal(String.valueOf(setting.get("lowestMoney")));
+            if (lowest != null && custom.compareTo(lowest) < 0) {
+                throw new BizException("很抱歉,当前最低充值金额不能低于" + plain(lowest) + "元");
+            }
+            payPrice = custom;
+            // 自定义金额自动匹配满足条件的套餐(金额从大到小取第一条)
+            if (toInt(setting.get("isMatchPlan"), DEFAULT_IS_MATCH_PLAN) != 0) {
+                matched = rechargePlanMapper.selectOne(Wrappers.<RechargePlan>lambdaQuery()
+                        .eq(RechargePlan::getStoreId, ApiContext.STORE_ID)
+                        .eq(RechargePlan::getIsDelete, 0)
+                        .le(RechargePlan::getMoney, custom)
+                        .orderByDesc(RechargePlan::getMoney)
+                        .last("limit 1"));
+            }
+        }
+
+        BigDecimal giftMoney = matched == null ? BigDecimal.ZERO : Money.of(matched.getGiftMoney());
+        int now = now();
+        RechargeOrder order = new RechargeOrder();
+        order.setUserId(user.getUserId());
+        order.setOrderNo("RC" + OrderNo.next());
+        order.setRechargeType(rechargeType);
+        order.setPlanId(matched == null ? 0 : matched.getPlanId());
+        order.setPayPrice(payPrice);
+        order.setGiftMoney(giftMoney);
+        order.setActualMoney(Money.add(payPrice, giftMoney));
+        order.setPayStatus(PAY_STATUS_PENDING);
+        order.setPlatform(platform);
+        order.setStoreId(ApiContext.STORE_ID);
+        order.setCreateTime(now);
+        order.setUpdateTime(now);
+        rechargeOrderMapper.insert(order);
+
+        if (matched != null) {
+            RechargeOrderPlan snapshot = new RechargeOrderPlan();
+            snapshot.setOrderId(order.getOrderId());
+            snapshot.setPlanId(matched.getPlanId());
+            snapshot.setPlanName(matched.getPlanName());
+            snapshot.setMoney(Money.of(matched.getMoney()));
+            snapshot.setGiftMoney(giftMoney);
+            snapshot.setStoreId(ApiContext.STORE_ID);
+            snapshot.setCreateTime(now);
+            snapshot.setUpdateTime(now);
+            rechargeOrderPlanMapper.insert(snapshot);
+        }
+        return order;
+    }
+
+    /**
+     * 发起第三方支付,返回客户端调起支付所需的参数。
+     *
+     * <p>PHP 对非微信 / 支付宝方式直接返回空数组(不报错);微信 / 支付宝当前未接入,
+     * 接入后在此补充 unifiedorder 与 PaymentTrade 记录。
+     */
+    private Object unifiedOrder(String method, String client, RechargeOrder order) {
+        if (!isThirdParty(method)) {
+            return new ArrayList<>();
+        }
+        // 微信小程序需要 openid(PHP extraAsUnify),此处接入后补充
+        throw new BizException("很抱歉,第三方支付(微信 / 支付宝)尚未接入,暂时无法完成充值");
+    }
+
+    // ------------------------------------------------------------------ 交易查询
+
+    /** 交易查询 {isPay}(PHP Recharge::tradeQuery) */
+    public Map<String, Object> tradeQuery(String outTradeNo, String method) {
+        boolean isPay = false;
+        if (isThirdParty(method)) {
+            // 第三方支付查询未接入:PHP 在未配置支付方式时会返回 false
+            isPay = false;
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("isPay", isPay);
+        return data;
+    }
+
+    // ------------------------------------------------------------------ 充值记录
+
+    /** 我的充值记录(只列已支付,分页 15) */
+    public PageVO<ObjectNode> orderList(User user, int page) {
+        Page<RechargeOrder> result = rechargeOrderMapper.selectPage(
+                new Page<>(page <= 0 ? 1 : page, PAGE_SIZE),
+                Wrappers.<RechargeOrder>lambdaQuery()
+                        .eq(RechargeOrder::getUserId, user.getUserId())
+                        .eq(RechargeOrder::getStoreId, ApiContext.STORE_ID)
+                        .eq(RechargeOrder::getPayStatus, PAY_STATUS_SUCCESS)
+                        .orderByDesc(RechargeOrder::getCreateTime));
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (RechargeOrder order : result.getRecords()) {
+            ObjectNode node = view.hide(view.of(order), ORDER_HIDDEN);
+            node.put("payPrice", Money.number2(order.getPayPrice()));
+            node.put("giftMoney", Money.number2(order.getGiftMoney()));
+            node.put("actualMoney", Money.number2(order.getActualMoney()));
+            nodes.add(node);
+        }
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        long lastPage = result.getPages();
+        long current = result.getCurrent();
+        vo.setCurrentPage(current > lastPage ? (lastPage > 0 ? lastPage : 1) : current);
+        vo.setLastPage(lastPage);
+        vo.setData(nodes);
+        return vo;
+    }
+
+    // ------------------------------------------------------------------ 支付成功(供回调复用)
+
+    /**
+     * 充值订单支付成功处理,对齐 PHP app/api/service/recharge/PaySuccess::handle。
+     *
+     * <p>更新订单为已支付、累加用户余额、写余额变动明细;重复回调幂等(已支付直接返回)。
+     * 当前无第三方支付渠道,该方法由后续批次的支付回调调用。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void paySuccess(String orderNo, String method, Integer tradeId) {
+        RechargeOrder order = rechargeOrderMapper.selectOne(Wrappers.<RechargeOrder>lambdaQuery()
+                .eq(RechargeOrder::getOrderNo, orderNo)
+                .last("limit 1"));
+        if (order == null) {
+            throw new BizException("未找到该订单信息");
+        }
+        if (Integer.valueOf(PAY_STATUS_SUCCESS).equals(order.getPayStatus())) {
+            return;
+        }
+        int now = now();
+        rechargeOrderMapper.update(null, Wrappers.<RechargeOrder>lambdaUpdate()
+                .set(RechargeOrder::getPayStatus, PAY_STATUS_SUCCESS)
+                .set(RechargeOrder::getPayTime, now)
+                .set(RechargeOrder::getPayMethod, method)
+                .set(RechargeOrder::getTradeId, tradeId == null ? 0 : tradeId)
+                .set(RechargeOrder::getUpdateTime, now)
+                .eq(RechargeOrder::getOrderId, order.getOrderId()));
+        BigDecimal actualMoney = Money.of(order.getActualMoney());
+        userMapper.update(null, Wrappers.<User>lambdaUpdate()
+                .setSql("balance = balance + " + actualMoney.toPlainString())
+                .eq(User::getUserId, order.getUserId()));
+        UserBalanceLog log = new UserBalanceLog();
+        log.setUserId(order.getUserId());
+        log.setScene(BALANCE_SCENE_RECHARGE);
+        log.setMoney(actualMoney);
+        log.setDescribe("用户充值:" + order.getOrderNo());
+        log.setStoreId(order.getStoreId());
+        log.setCreateTime(now);
+        balanceLogMapper.insert(log);
+    }
+
+    // ------------------------------------------------------------------ 工具
+
+    private static boolean isThirdParty(String method) {
+        return "wechat".equals(method) || "alipay".equals(method);
+    }
+
+    private static Object pickFirst(Map<?, ?> values, String camel, String snake) {
+        Object value = values.get(camel);
+        return value == null ? values.get(snake) : value;
+    }
+
+    private static int pickInt(Map<?, ?> values, String camel, String snake, int def) {
+        Object value = pickFirst(values, camel, snake);
+        return value == null ? def : toInt(value, def);
+    }
+
+    private static String pickStr(Map<?, ?> values, String camel, String snake, String def) {
+        Object value = pickFirst(values, camel, snake);
+        return value == null || String.valueOf(value).isEmpty() ? def : String.valueOf(value);
+    }
+
+    private static Object pickNumber(Map<?, ?> values, String camel, String snake, String def) {
+        Object value = pickFirst(values, camel, snake);
+        String raw = value == null ? def : String.valueOf(value);
+        try {
+            return new BigDecimal(raw.trim());
+        } catch (NumberFormatException e) {
+            return new BigDecimal(def);
+        }
+    }
+
+    private static int toInt(Object value, int def) {
+        if (value == null) {
+            return def;
+        }
+        if (value instanceof Boolean b) {
+            return b ? 1 : 0;
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return def;
+        }
+    }
+
+    private static BigDecimal parseDecimal(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(value.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /** 对齐 PHP 数值转字符串(去掉无意义的尾随 0):10.00 → 10 */
+    private static String plain(BigDecimal value) {
+        return value.stripTrailingZeros().toPlainString();
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+}

+ 369 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiRefundService.java

@@ -0,0 +1,369 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.Express;
+import cn.yoshop.common.entity.OrderGoods;
+import cn.yoshop.common.entity.OrderRefund;
+import cn.yoshop.common.entity.OrderRefundAddress;
+import cn.yoshop.common.entity.OrderRefundImage;
+import cn.yoshop.common.entity.UploadFile;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.ExpressMapper;
+import cn.yoshop.common.mapper.OrderGoodsMapper;
+import cn.yoshop.common.mapper.OrderRefundAddressMapper;
+import cn.yoshop.common.mapper.OrderRefundImageMapper;
+import cn.yoshop.common.mapper.OrderRefundMapper;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
+import cn.yoshop.store.service.RegionService;
+import cn.yoshop.store.service.UploadFileService;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端售后服务,对齐 PHP app/api/{controller,model}/Refund、app/api/model/OrderRefund。
+ *
+ * <p>口径:
+ * <ul>
+ *   <li>列表分页 15、按申请时间倒序,state 为售后单状态(-1 表示全部);</li>
+ *   <li>出参隐藏 storeId / updateTime,追加 stateText;orderGoods 按客户端订单商品口径裁剪;</li>
+ *   <li>详情额外带凭证图片(images.file)、退货地址(含省市区名称)、用户发货物流公司;</li>
+ *   <li>申请售后:订单商品必须属于当前用户且尚未申请过售后;提交后为「待审核」。</li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiRefundService {
+
+    /** 售后单隐藏字段(PHP api\model\OrderRefund::$hidden) */
+    private static final String[] REFUND_HIDDEN = {"storeId", "updateTime"};
+    /** 订单商品隐藏字段(PHP api\model\OrderGoods::$hidden) */
+    private static final String[] GOODS_HIDDEN = {"image", "content", "storeId", "createTime"};
+    /** 物流公司隐藏字段(PHP api\model\Express::$hidden) */
+    private static final String[] EXPRESS_HIDDEN = {
+            "kuaidi100Code", "kdniaoCode", "sort", "storeId", "createTime", "updateTime"
+    };
+    /** 文件隐藏字段(PHP api\model\UploadFile::$hidden) */
+    private static final String[] FILE_HIDDEN = {
+            "filePath", "fileName", "groupId", "channel", "storage", "domain", "fileSize", "fileExt",
+            "cover", "uploaderId", "isRecycle", "isDelete", "storeId", "createTime", "updateTime"
+    };
+
+    private static final int PAGE_SIZE = 15;
+
+    private static final int TYPE_RETURN = 10;
+    private static final int TYPE_EXCHANGE = 20;
+    private static final int AUDIT_WAIT = 0;
+    private static final int AUDIT_REVIEWED = 10;
+    private static final int STATUS_NORMAL = 0;
+    private static final int STATUS_REJECTED = 10;
+    private static final int STATUS_COMPLETED = 20;
+    private static final int STATUS_CANCELLED = 30;
+
+    private final OrderRefundMapper orderRefundMapper;
+    private final OrderRefundImageMapper orderRefundImageMapper;
+    private final OrderRefundAddressMapper orderRefundAddressMapper;
+    private final OrderGoodsMapper orderGoodsMapper;
+    private final ExpressMapper expressMapper;
+    private final UploadFileService uploadFileService;
+    private final RegionService regionService;
+    private final ApiView view;
+
+    // ------------------------------------------------------------------ 列表 / 详情
+
+    /** 售后单列表(分页 15) */
+    public PageVO<ObjectNode> list(User user, int state, int page) {
+        LambdaQueryWrapper<OrderRefund> qw = Wrappers.<OrderRefund>lambdaQuery()
+                .eq(OrderRefund::getUserId, user.getUserId())
+                .eq(OrderRefund::getStoreId, ApiContext.STORE_ID)
+                .eq(state > -1, OrderRefund::getStatus, state)
+                .orderByDesc(OrderRefund::getCreateTime);
+        Page<OrderRefund> result = orderRefundMapper.selectPage(
+                new Page<>(page <= 0 ? 1 : page, PAGE_SIZE), qw);
+        List<OrderRefund> records = result.getRecords();
+        assembleGoods(records);
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (OrderRefund refund : records) {
+            nodes.add(refundNode(refund, false));
+        }
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        long lastPage = result.getPages();
+        long current = result.getCurrent();
+        vo.setCurrentPage(current > lastPage ? (lastPage > 0 ? lastPage : 1) : current);
+        vo.setLastPage(lastPage);
+        vo.setData(nodes);
+        return vo;
+    }
+
+    /** 售后单详情 {detail} */
+    public Map<String, Object> detail(User user, int orderRefundId) {
+        OrderRefund refund = requireRefund(user, orderRefundId);
+        assembleGoods(List.of(refund));
+        assembleImages(refund);
+        assembleAddress(refund);
+        assembleExpress(refund);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("detail", refundNode(refund, true));
+        return data;
+    }
+
+    // ------------------------------------------------------------------ 申请 / 发货
+
+    /** 申请售后前的订单商品信息 {goods} */
+    public Map<String, Object> goods(User user, int orderGoodsId) {
+        OrderGoods goods = requireOrderGoods(user, orderGoodsId);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("goods", goodsNode(goods, true));
+        return data;
+    }
+
+    /** 申请售后(表单:type / content / images[]) */
+    @Transactional(rollbackFor = Exception.class)
+    public String apply(User user, int orderGoodsId, Map<String, Object> form) {
+        OrderGoods goods = requireOrderGoods(user, orderGoodsId);
+        Integer type = Params.intOrNull(form, "type");
+        if (type == null || (type != TYPE_RETURN && type != TYPE_EXCHANGE)) {
+            throw new BizException("请选择售后类型");
+        }
+        int now = now();
+        OrderRefund refund = new OrderRefund();
+        refund.setOrderGoodsId(orderGoodsId);
+        refund.setOrderId(goods.getOrderId());
+        refund.setUserId(user.getUserId());
+        refund.setType(type);
+        refund.setApplyDesc(Params.str(form, "content", ""));
+        refund.setAuditStatus(AUDIT_WAIT);
+        refund.setStatus(STATUS_NORMAL);
+        refund.setIsUserSend(0);
+        refund.setIsReceipt(0);
+        refund.setStoreId(ApiContext.STORE_ID);
+        refund.setCreateTime(now);
+        refund.setUpdateTime(now);
+        orderRefundMapper.insert(refund);
+
+        List<Integer> imageIds = Params.intList(form.get("images"));
+        if (!imageIds.isEmpty()) {
+            for (Integer imageId : imageIds) {
+                OrderRefundImage image = new OrderRefundImage();
+                image.setOrderRefundId(refund.getOrderRefundId());
+                image.setImageId(imageId);
+                image.setStoreId(ApiContext.STORE_ID);
+                image.setCreateTime(now);
+                orderRefundImageMapper.insert(image);
+            }
+        }
+        return "提交成功";
+    }
+
+    /** 用户发货(表单:expressId / expressNo) */
+    @Transactional(rollbackFor = Exception.class)
+    public String delivery(User user, int orderRefundId, Map<String, Object> form) {
+        OrderRefund refund = requireRefund(user, orderRefundId);
+        if (!Integer.valueOf(TYPE_RETURN).equals(refund.getType())
+                || !Integer.valueOf(AUDIT_REVIEWED).equals(refund.getAuditStatus())
+                || (refund.getIsUserSend() != null && refund.getIsUserSend() != 0)) {
+            throw new BizException("当前售后单不合法,不允许该操作");
+        }
+        Integer expressId = Params.intOrNull(form, "expressId");
+        if (expressId == null || expressId <= 0) {
+            throw new BizException("请选择物流公司");
+        }
+        String expressNo = Params.strOrNull(form, "expressNo");
+        if (expressNo == null || expressNo.isBlank()) {
+            throw new BizException("请填写物流单号");
+        }
+        int now = now();
+        orderRefundMapper.update(null, Wrappers.<OrderRefund>lambdaUpdate()
+                .set(OrderRefund::getIsUserSend, 1)
+                .set(OrderRefund::getSendTime, now)
+                .set(OrderRefund::getExpressId, String.valueOf(expressId))
+                .set(OrderRefund::getExpressNo, expressNo)
+                .set(OrderRefund::getUpdateTime, now)
+                .eq(OrderRefund::getOrderRefundId, orderRefundId));
+        return "操作成功";
+    }
+
+    // ------------------------------------------------------------------ 查询辅助
+
+    /** 当前用户的售后单,不存在时报错(PHP OrderRefund::getDetail) */
+    private OrderRefund requireRefund(User user, int orderRefundId) {
+        OrderRefund refund = orderRefundMapper.selectOne(Wrappers.<OrderRefund>lambdaQuery()
+                .eq(OrderRefund::getUserId, user.getUserId())
+                .eq(OrderRefund::getOrderRefundId, orderRefundId)
+                .last("limit 1"));
+        if (refund == null) {
+            throw new BizException("未找到该售后单");
+        }
+        return refund;
+    }
+
+    /** 当前用户可申请售后的订单商品(PHP OrderRefund::getRefundGoods) */
+    private OrderGoods requireOrderGoods(User user, int orderGoodsId) {
+        OrderGoods goods = orderGoodsMapper.selectOne(Wrappers.<OrderGoods>lambdaQuery()
+                .eq(OrderGoods::getOrderGoodsId, orderGoodsId)
+                .eq(OrderGoods::getUserId, user.getUserId())
+                .last("limit 1"));
+        if (goods == null) {
+            throw new BizException("未找到订单商品信息");
+        }
+        long exists = orderRefundMapper.selectCount(Wrappers.<OrderRefund>lambdaQuery()
+                .eq(OrderRefund::getOrderGoodsId, orderGoodsId));
+        if (exists > 0) {
+            throw new BizException("当前商品已申请售后");
+        }
+        UploadFile image = uploadFileService.find(goods.getImageId());
+        goods.setImage(image);
+        return goods;
+    }
+
+    /** 批量装配订单商品与商品图 */
+    private void assembleGoods(List<OrderRefund> refunds) {
+        if (refunds == null || refunds.isEmpty()) {
+            return;
+        }
+        List<Integer> goodsIds = refunds.stream().map(OrderRefund::getOrderGoodsId)
+                .filter(id -> id != null && id > 0).distinct().toList();
+        Map<Integer, OrderGoods> goodsMap = goodsIds.isEmpty() ? Map.of()
+                : orderGoodsMapper.selectBatchIds(goodsIds).stream()
+                .collect(java.util.stream.Collectors.toMap(OrderGoods::getOrderGoodsId, g -> g, (a, b) -> a));
+        Map<Integer, UploadFile> images = uploadFileService.mapByIds(
+                goodsMap.values().stream().map(OrderGoods::getImageId).toList());
+        goodsMap.values().forEach(g -> g.setImage(images.get(g.getImageId())));
+        refunds.forEach(r -> r.setOrderGoods(goodsMap.get(r.getOrderGoodsId())));
+    }
+
+    /** 凭证图片 + 文件 */
+    private void assembleImages(OrderRefund refund) {
+        List<OrderRefundImage> images = orderRefundImageMapper.selectList(
+                Wrappers.<OrderRefundImage>lambdaQuery()
+                        .eq(OrderRefundImage::getOrderRefundId, refund.getOrderRefundId())
+                        .orderByAsc(OrderRefundImage::getId));
+        Map<Integer, UploadFile> files = uploadFileService.mapByIds(
+                images.stream().map(OrderRefundImage::getImageId).toList());
+        images.forEach(img -> img.setFile(files.get(img.getImageId())));
+        refund.setImages(images);
+    }
+
+    /** 退货地址(含省市区名称,PHP OrderRefundAddress::$append = ['region']) */
+    private void assembleAddress(OrderRefund refund) {
+        OrderRefundAddress address = orderRefundAddressMapper.selectOne(
+                Wrappers.<OrderRefundAddress>lambdaQuery()
+                        .eq(OrderRefundAddress::getOrderRefundId, refund.getOrderRefundId())
+                        .last("limit 1"));
+        if (address != null) {
+            Map<String, String> region = new LinkedHashMap<>();
+            region.put("province", regionService.getNameById(address.getProvinceId()));
+            region.put("city", regionService.getNameById(address.getCityId()));
+            region.put("region", regionService.getNameById(address.getRegionId()));
+            address.setRegion(region);
+        }
+        refund.setAddress(address);
+    }
+
+    /** 用户发货物流公司(express_id 为 varchar) */
+    private void assembleExpress(OrderRefund refund) {
+        Integer expressId = parseIntOrNull(refund.getExpressId());
+        refund.setExpress(expressId == null || expressId <= 0 ? null : expressMapper.selectById(expressId));
+    }
+
+    // ------------------------------------------------------------------ 出参组装
+
+    /** 售后单节点:字段裁剪 + stateText + 关联裁剪 */
+    private ObjectNode refundNode(OrderRefund refund, boolean detail) {
+        ObjectNode node = view.of(refund);
+        view.hide(node, REFUND_HIDDEN);
+        if (detail) {
+            // 详情不输出 order_data / user / order_no(PHP 未 with 订单与会员)
+            node.remove(List.of("orderData", "user", "orderNo"));
+        } else {
+            node.remove(List.of("orderData", "user", "orderNo", "images", "express", "address"));
+        }
+        node.put("stateText", stateText(refund));
+        if (node.get("orderGoods") instanceof ObjectNode goodsNode) {
+            view.hide(goodsNode, GOODS_HIDDEN);
+            goodsNode.remove("refund");
+        }
+        if (node.get("express") instanceof ObjectNode expressNode) {
+            view.hide(expressNode, EXPRESS_HIDDEN);
+        }
+        if (node.get("images") instanceof com.fasterxml.jackson.databind.node.ArrayNode images) {
+            for (var item : images) {
+                if (item instanceof ObjectNode imageNode) {
+                    if (imageNode.get("file") instanceof ObjectNode fileNode) {
+                        view.hide(fileNode, FILE_HIDDEN);
+                    }
+                    String url = imageNode.path("file").path("previewUrl").asText(null);
+                    imageNode.put("imageUrl", url);
+                }
+            }
+        }
+        return node;
+    }
+
+    /** 订单商品节点(apply 页需要商品图,PHP 由 image 关联 bind 出 goods_image) */
+    private ObjectNode goodsNode(OrderGoods goods, boolean withRefundKey) {
+        ObjectNode node = view.of(goods);
+        view.hide(node, GOODS_HIDDEN);
+        node.put("goodsImage", goods.getImage() == null ? null : goods.getImage().getPreviewUrl());
+        if (withRefundKey && !node.has("refund")) {
+            node.putNull("refund");
+        }
+        return node;
+    }
+
+    /** 售后单状态文字,对齐 PHP OrderRefund::getStateTextAttr */
+    private static String stateText(OrderRefund refund) {
+        Integer status = refund.getStatus();
+        Integer type = refund.getType();
+        if (Integer.valueOf(STATUS_COMPLETED).equals(status)) {
+            return Integer.valueOf(TYPE_RETURN).equals(type) ? "已同意退货并已退款" : "已同意换货";
+        }
+        if (Integer.valueOf(STATUS_CANCELLED).equals(status)) {
+            return "已取消";
+        }
+        if (Integer.valueOf(STATUS_REJECTED).equals(status)) {
+            return Integer.valueOf(TYPE_RETURN).equals(type) ? "已拒绝退货退款" : "已拒绝换货";
+        }
+        if (Integer.valueOf(STATUS_NORMAL).equals(status)) {
+            if (Integer.valueOf(AUDIT_WAIT).equals(refund.getAuditStatus())) {
+                return "等待审核中";
+            }
+            if (Integer.valueOf(TYPE_RETURN).equals(type)) {
+                return refund.getIsUserSend() != null && refund.getIsUserSend() != 0
+                        ? "已发货,待平台确认" : "已同意退货,请及时发货";
+            }
+        }
+        // 其余场景 PHP 返回原始值(未定义),这里保持 null
+        return null;
+    }
+
+    private static Integer parseIntOrNull(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        try {
+            return (int) Double.parseDouble(value.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+}

+ 90 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiSmsService.java

@@ -0,0 +1,90 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.util.PhpStr;
+import cn.yoshop.utils.RedisUtils;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 短信验证码,对齐 PHP yiovo\captcha\CaptchaApi::createSMS / checkSMS 的存储与校验规则。
+ *
+ * <p>存储:Redis 字符串 {@code yoshop:api:sms:{mobile}} → {@code {"code":"123456","times":5}},TTL 1800 秒。
+ * 与 PHP 缓存键(captchaSMS.{mobile})不同名,因为 Java 侧不复用 PHP 缓存;语义保持一致:
+ * <ul>
+ *   <li>未获取过验证码 → "短信验证码不存在,请重新获取"</li>
+ *   <li>错误次数用尽 → 删除后提示 "短信验证码已超出错误次数,请重新获取"</li>
+ *   <li>验证码不匹配 → 次数减一,提示 "短信验证码不正确,请重新填写"</li>
+ *   <li>校验通过 → 立即删除,不可重复使用</li>
+ * </ul>
+ *
+ * <p><b>当前为骨架</b>:短信通道(阿里云 / 腾讯云 / 七牛云)尚未接入,{@link #send} 直接给出明确文案;
+ * 验证码的写入接口 {@link #saveCode} 已就绪,接入通道后调用它即可,也可供自测脚本直接写 Redis。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiSmsService {
+
+    /** 验证码缓存键前缀 */
+    public static final String KEY_PREFIX = "yoshop:api:sms:";
+    /** 有效期(秒),对齐 PHP CaptchaApi::$expire */
+    private static final int EXPIRE_SECONDS = 1800;
+    /** 允许的错误次数,对齐 PHP CaptchaApi::$checkTimes */
+    private static final int CHECK_TIMES = 5;
+
+    private final RedisUtils redisUtils;
+
+    /** 保存验证码(供短信通道与自测使用),返回生成的 key */
+    public void saveCode(String mobile, String code) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("code", code);
+        data.put("times", CHECK_TIMES);
+        redisUtils.setString(key(mobile), PhpStr.jsonEncode(data), EXPIRE_SECONDS);
+    }
+
+    /**
+     * 发送短信验证码。
+     *
+     * @return 生成的验证码(接入通道后返回 null 表示已下发)
+     */
+    public String send(String mobile) {
+        throw new BizException("很抱歉,短信服务尚未配置,暂无法发送验证码");
+    }
+
+    /**
+     * 校验短信验证码,失败抛业务异常(对齐 PHP checkSMS 的文案)。
+     */
+    @SuppressWarnings("unchecked")
+    public void check(String mobile, String code) {
+        if (mobile == null || mobile.isBlank()) {
+            throw new BizException("请输入手机号");
+        }
+        String raw = redisUtils.getString(key(mobile));
+        Object decoded = raw == null ? null : PhpStr.jsonDecodeArray(raw);
+        if (!(decoded instanceof Map<?, ?> captcha) || captcha.get("code") == null) {
+            throw new BizException("短信验证码不存在,请重新获取");
+        }
+        int times = captcha.get("times") == null ? 0 : (int) Double.parseDouble(String.valueOf(captcha.get("times")));
+        if (times <= 0) {
+            redisUtils.deleteString(key(mobile));
+            throw new BizException("短信验证码已超出错误次数,请重新获取");
+        }
+        String saved = String.valueOf(captcha.get("code"));
+        if (!saved.equals(code == null ? "" : code.trim())) {
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("code", saved);
+            data.put("times", times - 1);
+            redisUtils.setString(key(mobile), PhpStr.jsonEncode(data), EXPIRE_SECONDS);
+            throw new BizException("短信验证码不正确,请重新填写");
+        }
+        redisUtils.deleteString(key(mobile));
+    }
+
+    private static String key(String mobile) {
+        return KEY_PREFIX + mobile;
+    }
+}

+ 188 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiUserCouponService.java

@@ -0,0 +1,188 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.Coupon;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserCoupon;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.CouponMapper;
+import cn.yoshop.common.mapper.UserCouponMapper;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 我的优惠券,对齐 PHP app/api/{controller,model}/UserCoupon。
+ *
+ * <p>口径:
+ * <ul>
+ *   <li>列表分页 15,按用户过滤;dataType 支持 all / isUsable / isUnused / isExpire / isUse;</li>
+ *   <li>amount 为订单消费金额,只保留 minPrice 门槛满足的券;</li>
+ *   <li>出参隐藏 storeId / createTime / updateTime,state 由实体的获取器追加;</li>
+ *   <li>领取:券可领(未发完、未过期)+ 当前用户未领过 → 写入领取记录并累计 receive_num。</li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiUserCouponService {
+
+    /** 优惠券隐藏字段(PHP api\model\UserCoupon::$hidden) */
+    private static final String[] HIDDEN = {"storeId", "createTime", "updateTime"};
+
+    private static final int PAGE_SIZE = 15;
+    private static final int EXPIRE_TYPE_FIXED = 20;
+    private static final int RECEIVE_EXPIRE_TYPE = 10;
+
+    private final UserCouponMapper userCouponMapper;
+    private final CouponMapper couponMapper;
+    private final ApiView view;
+
+    // ------------------------------------------------------------------ 列表
+
+    /** 我的优惠券列表(分页 15) */
+    public PageVO<ObjectNode> list(User user, String dataType, BigDecimal amount, int page) {
+        LambdaQueryWrapper<UserCoupon> qw = Wrappers.<UserCoupon>lambdaQuery()
+                .eq(UserCoupon::getUserId, user.getUserId());
+        applyDataType(qw, dataType);
+        if (amount != null && amount.compareTo(BigDecimal.ZERO) > 0) {
+            qw.le(UserCoupon::getMinPrice, amount);
+        }
+        Page<UserCoupon> result = userCouponMapper.selectPage(
+                new Page<>(page <= 0 ? 1 : page, PAGE_SIZE), qw);
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (UserCoupon coupon : result.getRecords()) {
+            nodes.add(view.hide(view.of(coupon), HIDDEN));
+        }
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        long lastPage = result.getPages();
+        long current = result.getCurrent();
+        vo.setCurrentPage(current > lastPage ? (lastPage > 0 ? lastPage : 1) : current);
+        vo.setLastPage(lastPage);
+        vo.setData(nodes);
+        return vo;
+    }
+
+    /** 列表筛选条件,对齐 PHP UserCoupon::getFilter */
+    private void applyDataType(LambdaQueryWrapper<UserCoupon> qw, String dataType) {
+        String type = dataType == null || dataType.isBlank() ? "all" : dataType;
+        int now = now();
+        switch (type) {
+            case "isUsable" -> qw.eq(UserCoupon::getIsUse, 0)
+                    .eq(UserCoupon::getIsExpire, 0)
+                    .le(UserCoupon::getStartTime, now)
+                    .gt(UserCoupon::getEndTime, now);
+            case "isUnused" -> qw.eq(UserCoupon::getIsUse, 0)
+                    .eq(UserCoupon::getIsExpire, 0)
+                    .gt(UserCoupon::getEndTime, now);
+            case "isExpire" -> qw.eq(UserCoupon::getIsExpire, 1);
+            case "isUse" -> qw.eq(UserCoupon::getIsUse, 1);
+            default -> {
+                // all:不加条件
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------ 领取
+
+    /** 领取优惠券,返回提示文案 */
+    @Transactional(rollbackFor = Exception.class)
+    public String receive(User user, int couponId) {
+        Coupon coupon = couponMapper.selectById(couponId);
+        checkReceive(user, coupon);
+        addRecord(user, coupon);
+        return "领取成功";
+    }
+
+    /** 领取校验,对齐 PHP Coupon::checkReceive + UserCoupon::checktUserCoupon */
+    private void checkReceive(User user, Coupon coupon) {
+        if (coupon == null) {
+            throw new BizException("很抱歉,当前优惠券不存在");
+        }
+        if (coupon.getTotalNum() != null && coupon.getTotalNum() > -1
+                && coupon.getReceiveNum() != null && coupon.getReceiveNum() >= coupon.getTotalNum()) {
+            throw new BizException("很抱歉,当前优惠券已发完");
+        }
+        if (Integer.valueOf(EXPIRE_TYPE_FIXED).equals(coupon.getExpireType())
+                && coupon.getEndTime() != null && (long) coupon.getEndTime() + 86400 < now()) {
+            throw new BizException("很抱歉,当前优惠券已过期");
+        }
+        long exists = userCouponMapper.selectCount(Wrappers.<UserCoupon>lambdaQuery()
+                .eq(UserCoupon::getCouponId, coupon.getCouponId())
+                .eq(UserCoupon::getUserId, user.getUserId()));
+        if (exists > 0) {
+            throw new BizException("当前用户已领取该优惠券");
+        }
+    }
+
+    /** 写入领取记录并累计已领取数量(PHP UserCoupon::add) */
+    private void addRecord(User user, Coupon coupon) {
+        int now = now();
+        // 计算有效期:领取后生效 → 当前时间 + 有效天数;固定时间 → 券自身的起止时间
+        int startTime;
+        int endTime;
+        if (Integer.valueOf(RECEIVE_EXPIRE_TYPE).equals(coupon.getExpireType())) {
+            startTime = now;
+            endTime = now + (coupon.getExpireDay() == null ? 0 : coupon.getExpireDay() * 86400);
+        } else {
+            startTime = coupon.getStartTime() == null ? 0 : coupon.getStartTime();
+            endTime = coupon.getEndTime() == null ? 0 : coupon.getEndTime();
+        }
+        UserCoupon record = new UserCoupon();
+        record.setCouponId(coupon.getCouponId());
+        record.setName(coupon.getName());
+        record.setCouponType(coupon.getCouponType());
+        record.setReducePrice(coupon.getReducePrice());
+        record.setDiscount(coupon.getDiscount());
+        record.setMinPrice(coupon.getMinPrice());
+        record.setExpireType(coupon.getExpireType());
+        record.setExpireDay(coupon.getExpireDay());
+        record.setStartTime(startTime);
+        record.setEndTime(endTime);
+        record.setApplyRange(coupon.getApplyRange());
+        record.setApplyRangeConfig(coupon.getApplyRangeConfig());
+        record.setDescribe(coupon.getDescribe());
+        record.setIsExpire(0);
+        record.setIsUse(0);
+        record.setUserId(user.getUserId());
+        record.setStoreId(ApiContext.STORE_ID);
+        record.setCreateTime(now);
+        record.setUpdateTime(now);
+        userCouponMapper.insert(record);
+        couponMapper.update(null, Wrappers.<Coupon>lambdaUpdate()
+                .setSql("receive_num = receive_num + 1")
+                .eq(Coupon::getCouponId, coupon.getCouponId()));
+    }
+
+    /** 解析 amount 参数(前端可能传字符串或数字) */
+    public static BigDecimal amount(Map<String, Object> params) {
+        String raw = Params.strOrNull(params, "amount");
+        if (raw == null || raw.isBlank()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(raw.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+}

+ 146 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiUserService.java

@@ -0,0 +1,146 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserCoupon;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.UserCouponMapper;
+import cn.yoshop.common.mapper.UserMapper;
+import cn.yoshop.common.util.Money;
+import cn.yoshop.common.util.Params;
+import cn.yoshop.store.service.MemberService;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端用户中心,对齐 PHP app/api/{controller,model}/User。
+ *
+ * <p>口径:
+ * <ul>
+ *   <li>用户信息按 PHP api\model\User::$hidden 裁剪(currentOauth / isDelete / storeId / createTime / updateTime 不输出);</li>
+ *   <li>手机号 11 位时中间四位打码(PHP hide_mobile);</li>
+ *   <li>头像与会员等级随用户信息一并返回(PHP 访问 $userInfo['avatar'] / ['grade'] 触发关联查询);</li>
+ *   <li>绑定手机号需先通过短信验证码校验,且该手机号不能已被其他账号占用。</li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiUserService {
+
+    /** 用户信息隐藏字段(PHP api\model\User::$hidden) */
+    private static final String[] USER_HIDDEN = {"isDelete", "storeId", "createTime", "updateTime"};
+
+    private final UserMapper userMapper;
+    private final UserCouponMapper userCouponMapper;
+    private final MemberService memberService;
+    private final ApiSmsService smsService;
+    private final ApiView view;
+
+    // ------------------------------------------------------------------ 用户信息
+
+    /** 当前用户详情 {userInfo} */
+    public Map<String, Object> info(User login) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("userInfo", node(login));
+        return data;
+    }
+
+    /**
+     * 用户节点:装配头像与等级,按 PHP 隐藏字段裁剪,手机号打码。
+     * 供 user/info 与 recharge/center(personal)复用。
+     */
+    public ObjectNode node(User user) {
+        memberService.assemble(List.of(user));
+        ObjectNode node = view.of(user);
+        view.hide(node, USER_HIDDEN);
+        node.put("mobile", hideMobile(user.getMobile()));
+        return node;
+    }
+
+    /** 账户资产 {assets: {balance, points, coupon}} */
+    public Map<String, Object> assets(User login) {
+        long couponCount = userCouponMapper.selectCount(Wrappers.<UserCoupon>lambdaQuery()
+                .eq(UserCoupon::getUserId, login.getUserId())
+                .eq(UserCoupon::getIsUse, 0)
+                .eq(UserCoupon::getIsExpire, 0)
+                .gt(UserCoupon::getEndTime, now()));
+        Map<String, Object> assets = new LinkedHashMap<>();
+        assets.put("balance", Money.number2(login.getBalance()));
+        assets.put("points", login.getPoints() == null ? 0 : login.getPoints());
+        assets.put("coupon", couponCount);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("assets", assets);
+        return data;
+    }
+
+    // ------------------------------------------------------------------ 修改资料
+
+    /** 绑定手机号(验证短信验证码 + 手机号唯一性) */
+    @Transactional(rollbackFor = Exception.class)
+    public String bindMobile(User login, Map<String, Object> form) {
+        String mobile = Params.str(form, "mobile", "");
+        String smsCode = Params.str(form, "smsCode", "");
+        if (mobile.isBlank()) {
+            throw new BizException("请输入手机号");
+        }
+        smsService.check(mobile, smsCode);
+        long exists = userMapper.selectCount(Wrappers.<User>lambdaQuery()
+                .eq(User::getMobile, mobile)
+                .eq(User::getIsDelete, 0)
+                .ne(User::getUserId, login.getUserId()));
+        if (exists > 0) {
+            throw new BizException("很抱歉,该手机号已绑定其他账户");
+        }
+        userMapper.update(null, Wrappers.<User>lambdaUpdate()
+                .set(User::getMobile, mobile)
+                .set(User::getUpdateTime, now())
+                .eq(User::getUserId, login.getUserId()));
+        login.setMobile(mobile);
+        return "恭喜您,手机号绑定成功";
+    }
+
+    /** 修改个人信息(头像 / 昵称),空值沿用原值 */
+    @Transactional(rollbackFor = Exception.class)
+    public String personal(User login, Map<String, Object> form) {
+        Integer avatarId = Params.intOrNull(form, "avatarId");
+        String nickName = Params.strOrNull(form, "nickName");
+        Integer newAvatarId = avatarId == null || avatarId <= 0 ? login.getAvatarId() : avatarId;
+        String newNickName = nickName == null || nickName.isBlank() ? login.getNickName() : nickName;
+        userMapper.update(null, Wrappers.<User>lambdaUpdate()
+                .set(User::getAvatarId, newAvatarId)
+                .set(User::getNickName, newNickName)
+                .set(User::getUpdateTime, now())
+                .eq(User::getUserId, login.getUserId()));
+        login.setAvatarId(newAvatarId);
+        login.setNickName(newNickName);
+        return "恭喜您,信息修改成功";
+    }
+
+    // ------------------------------------------------------------------ 工具
+
+    /** 11 位手机号中间四位打码,对齐 PHP hide_mobile */
+    static String hideMobile(String mobile) {
+        if (mobile == null || mobile.length() != 11) {
+            return mobile;
+        }
+        return mobile.substring(0, 3) + "****" + mobile.substring(7);
+    }
+
+    private static int now() {
+        return (int) (System.currentTimeMillis() / 1000);
+    }
+
+    /** 当前商城 ID(保持与其他 api 服务一致的取值入口) */
+    static int storeId() {
+        return ApiContext.STORE_ID;
+    }
+}

+ 19 - 4
yoshop-java/src/main/java/cn/yoshop/store/service/UploadService.java

@@ -36,6 +36,9 @@ public class UploadService {
 
     private static final int FILE_TYPE_IMAGE = 10;
     private static final int FILE_TYPE_VIDEO = 20;
+    /** 上传渠道:10 商家端、20 用户端(对齐 PHP UploadFile 的 channel 取值) */
+    private static final int CHANNEL_STORE = 10;
+    private static final int CHANNEL_USER = 20;
     private static final int STORE_ID = 10001;
 
     private final UploadFileMapper uploadFileMapper;
@@ -48,15 +51,27 @@ public class UploadService {
 
     /** 图片上传 */
     public UploadFile uploadImage(MultipartFile file, int groupId) {
-        return upload(file, FILE_TYPE_IMAGE, groupId);
+        return upload(file, FILE_TYPE_IMAGE, groupId, CHANNEL_STORE, 0);
     }
 
     /** 视频上传 */
     public UploadFile uploadVideo(MultipartFile file, int groupId) {
-        return upload(file, FILE_TYPE_VIDEO, groupId);
+        return upload(file, FILE_TYPE_VIDEO, groupId, CHANNEL_STORE, 0);
+    }
+
+    /**
+     * 图片上传(客户端):channel=20 表示用户端上传,uploaderId 记录上传者。
+     * 对齐 PHP app/api/service/Upload:表单字段 file、不分组、channel=20。
+     */
+    public UploadFile uploadImageByUser(MultipartFile file, int uploaderId) {
+        return upload(file, FILE_TYPE_IMAGE, 0, CHANNEL_USER, uploaderId);
     }
 
     private UploadFile upload(MultipartFile file, int fileType, int groupId) {
+        return upload(file, fileType, groupId, CHANNEL_STORE, 0);
+    }
+
+    private UploadFile upload(MultipartFile file, int fileType, int groupId, int channel, int uploaderId) {
         if (file == null || file.isEmpty()) {
             throw new BizException("文件上传失败:未接收到上传文件");
         }
@@ -81,7 +96,7 @@ public class UploadService {
 
             UploadFile record = new UploadFile();
             record.setGroupId(groupId);
-            record.setChannel(10);
+            record.setChannel(channel);
             record.setStorage(UploadFile.STORAGE_LOCAL);
             record.setDomain("");
             record.setFileType(fileType);
@@ -90,7 +105,7 @@ public class UploadService {
             record.setFileSize(bytes.length);
             record.setFileExt(ext);
             record.setCover("");
-            record.setUploaderId(0);
+            record.setUploaderId(uploaderId);
             record.setIsRecycle(0);
             record.setIsDelete(0);
             record.setStoreId(STORE_ID);

+ 111 - 0
接口实现进度-第12批-api.md

@@ -0,0 +1,111 @@
+# 接口实现进度(第 12 批 · 客户端 api 第 3 阶段)
+
+> 目标:把 PHP `app/api`(客户端:微信小程序 / H5)重写为 Java。
+> 接口总量 74 个,本批次为**第 3 批:用户中心 / 我的优惠券 / 余额积分明细 / 售后 / 充值 / 上传(18 个)**。
+> 累计:62 / 74。
+
+## 一、本批已实现(18 / 74)
+
+| 分组 | 接口 | 说明 |
+|---|---|---|
+| 用户中心 | `user/info`、`user/assets`、`user/bindMobile`、`user/personal` | 信息带头像与会员等级、手机号中间四位打码;资产为余额 / 积分 / 可用券数量;绑定手机号需短信验证码且手机号不可被他人占用 |
+| 我的优惠券 | `myCoupon/list`、`myCoupon/receive` | 分页 15,`dataType` 支持 all / isUsable / isUnused / isExpire / isUse,`amount` 按门槛过滤;领取后写领取记录并累计 `receive_num` |
+| 明细 | `balance.log/list`、`points.log/list` | 分页 15、按时间倒序,隐藏 `storeId` 与未加载的 `user` 关联 |
+| 售后 | `refund/list`、`refund/goods`、`refund/apply`、`refund/detail`、`refund/delivery` | 列表按状态筛选并追加 `stateText`;详情带凭证图片、退货地址(含省市区名称)、用户发货物流;申请售后后不可重复申请 |
+| 充值 | `recharge/center`、`recharge/submit`、`recharge/tradeQuery`、`recharge.order/list` | 充值中心含设置 / 用户 / 套餐 / 该客户端可用支付方式;下单支持套餐与自定义金额(自动匹配套餐);记录只列已支付订单 |
+| 上传 | `upload/image` | 表单字段 `file`,`checkLogin=1` 时强制登录并把上传者记为当前用户,渠道 20(用户端) |
+
+## 二、代码结构
+
+```
+api/service/ApiUserService            # user/info、assets、bindMobile、personal(含用户节点复用)
+api/service/ApiSmsService             # 短信验证码读写(Redis),发送为骨架
+api/service/ApiUserCouponService      # myCoupon list / receive
+api/service/ApiLogService             # balance.log / points.log
+api/service/ApiRefundService          # refund list / goods / apply / detail / delivery
+api/service/ApiRechargeService        # recharge center / submit / tradeQuery / order.list + 支付成功结算
+
+api/controller/ApiUserController      # user/*、balance.log/list、points.log/list
+api/controller/ApiRefundController    # refund/*
+api/controller/ApiRechargeController  # recharge/*、recharge.order/list
+api/controller/ApiUploadController    # upload/image
+
+store/service/UploadService           # 增补 uploadImageByUser(channel=20、记录 uploaderId)
+api/controller/ApiMarketingController  # 增补 myCoupon/list、myCoupon/receive
+```
+
+## 三、关键口径
+
+### 1. 短信验证码(`ApiSmsService`)
+
+- Redis 键 `yoshop:api:sms:{mobile}`,值 `{"code":"123456","times":5}`,TTL 1800 秒;
+- 未获取过 → 「短信验证码不存在,请重新获取」;错误次数用尽 → 删除并提示;不匹配 → 次数减一;通过 → 立即删除(用后即焚);
+- **发送通道为骨架**:阿里云 / 腾讯云 / 七牛云短信尚未接入,`send()` 直接返回明确文案;
+  接入后调用 `saveCode(mobile, code)` 即可,测试脚本也是直接写这个键。
+
+### 2. 用户信息与资产
+
+- 隐藏字段对齐 PHP `api\model\User::$hidden`(`currentOauth` / `isDelete` / `storeId` / `createTime` / `updateTime`);
+- 手机号 11 位时 `substr_replace` 语义打码(`18587721340` → `185****1340`),非 11 位原样返回;
+- `avatar` / `avatarUrl` / `grade` 由 `MemberService.assemble` 批量装配;
+- `assets.coupon` = 未使用、未过期且 `end_time > now` 的券数量。
+
+### 3. 售后服务
+
+| 项 | 规则 |
+|---|---|
+| 列表 | `state` 默认 -1(全部),> -1 时按 `status` 过滤;分页 15、`create_time` 倒序 |
+| stateText | 已完成(10 退货→「已同意退货并已退款」/ 20 换货→「已同意换货」)、已取消、已拒绝(按类型分文案)、进行中(待审核 / 已同意退货待发货 / 已发货待平台确认) |
+| 申请 | 订单商品必须属于当前用户;**该订单商品存在任意售后单即拒绝**(与 PHP 一致,含已取消单);`type` 只接受 10 / 20 |
+| 发货 | 仅「退货退款 + 已审核 + 未发货」可操作;`expressId > 0`、`expressNo` 非空 |
+| 详情 | 额外装配 `images.file`(含 `imageUrl`)、`address.region`(省 / 市 / 区名称)、`express` |
+
+### 4. 充值
+
+- 充值设置:库内无该项时用 PHP 默认值兜底(`isEntrance=1`、`isCustom=1`、`lowestMoney=10`、`isMatchPlan=1`、`describe=…`),
+  出参统一 camelCase;
+- 支付方式:取 `PaymentSettingService.options()` 中该客户端的 `methods`,只保留 `isEnable` 的项并**排除余额支付**;为空时报
+  「很抱歉,当前没有可用的支付方式,请检查后台支付设置」;
+- 下单:`planId > 0` → 套餐充值(20),否则自定义金额(10);自定义金额校验 `isCustom`、最低金额,
+  `isMatchPlan=1` 时按 `money <= 输入金额` 从大到小匹配套餐;`actualMoney = payPrice + giftMoney`;
+- **第三方支付为骨架**:`wechat` / `alipay` 直接给出「第三方支付(微信 / 支付宝)尚未接入」的明确文案;
+  其余方式按 PHP 语义不发起第三方支付(`payment` 为空数组);
+- `recharge.order/list` 只列 `payStatus=20`,隐藏 `tradeId` / `storeId` / `createTime` / `updateTime`;
+- `paySuccess(orderNo, method, tradeId)` 已实现:订单置已支付、累加用户余额、写余额明细「用户充值:{订单号}」,
+  重复回调幂等,供下一批支付回调复用。
+
+### 5. 文件上传
+
+- 复用商家端 `UploadService`,新增 `uploadImageByUser`:`channel=20`、`groupId=0`、`uploaderId` 记当前用户;
+- 出参只保留 `fileId` / `fileType` / `previewUrl` / `externalUrl`(对齐 PHP `api\model\UploadFile::$hidden`);
+- `checkLogin=1` 且未登录 → 401(PHP `UserService::isLogin(true)` 会抛错),`checkLogin=0` 允许匿名,上传者记 0。
+
+## 四、验证
+
+`python tools/test_api_batch3.py`(需 18080 在跑):**99 项断言全过**,跑完自动清理测试数据并把用户资料恢复原值。
+
+覆盖点:
+- 未登录:`user/info`、`user/assets`、`myCoupon/list`、`refund/list` 均 401;`upload/image` 在 `checkLogin=1` 时 401;
+- 用户信息:手机号打码、`avatarUrl` / `grade` 键、隐藏 `isDelete` / `storeId` / `createTime`、零 snake_case;
+- 资产:三字段齐全、余额两位小数字符串、领取券后数量 +1;
+- 明细:倒序、`perPage=15`、隐藏 `storeId` / `user`;
+- 绑定手机号:无验证码 / 验证码错误(次数减一)/ 绑定成功(落库 + 验证码用后即焚)/ 号码被占用;
+- 我的优惠券:券不存在、领取成功(记录 + 有效期 7 天 + `receive_num` +1)、重复领取被拒、
+  `isUsable` 含券、`amount=50` 过滤掉满 100 的券、`state` 文案、`discount` 为 0-10 小数;
+- 售后:商品信息、缺类型报错、申请落库(待审核)、重复申请被拒、详情 `stateText`、列表筛选与裁剪、
+  未审核不可发货、缺物流公司 / 单号报错、发货落库、发货后 `stateText` 与物流公司名称;
+- 充值:无支付方式报错、设置默认值、`personal` / `planList` / 支付方式排除余额、
+  缺参数与低于最低金额报错、自定义金额匹配套餐(赠 10、到账 110)、套餐下单、微信支付明确报错、
+  `tradeQuery` 返回 `isPay=false`、充值记录只列已支付且金额格式化;
+- 上传:登录校验、`fileId` / `previewUrl`、隐藏字段、`channel=20` 与上传者、匿名上传 `uploader_id=0`、
+  设置头像后 `user/info` 的 `avatarUrl` 生效。
+
+## 五、后续批次(12 个)
+
+| 批次 | 范围 | 接口数 |
+|---|---|---|
+| 第 4 批 | 登录(含微信小程序登录)、图形 / 短信验证码、收银台、支付回调 | 12 |
+
+外部依赖口径(已确认):微信小程序登录(jscode2session)与图形验证码真做;
+微信 / 支付宝支付、短信验证码先做可插拔骨架,未配置时返回明确错误文案。
+本批已把短信验证码的存储与校验、充值支付成功结算沉淀好,第 4 批只需补通道与回调入口。

+ 11 - 3
联调说明.md

@@ -116,11 +116,19 @@ NODE_OPTIONS=--openssl-legacy-provider ./node_modules/.bin/vue-cli-service serve
 
 ## 七、客户端(api)联调
 
-第 10 批完成客户端 api 第一阶段(22 / 74 个接口),详见 `接口实现进度-第10批-api.md`。
+客户端 api 共 74 个接口,已分三批实现 62 个,详见 `接口实现进度-第10批-api.md` /
+`接口实现进度-第11批-api.md` / `接口实现进度-第12批-api.md`。
 
 - 基础地址:`/api/**`,与 PHP 的 `/index.php?s=/api/xxx` 对应
 - 请求头:`platform`(MP-WEIXIN / H5)、`Access-Token`(用户 token,可无)
 - 登录态:Redis `yoshop:api:token:{token}`,值=用户 ID;`/api/**` 只解析不拦截,
-  需登录的接口(购物车、订单等,第 2 批起)由接口内部判定并返回 401「请先登录后再访问」
+  需登录的接口(购物车、订单、用户中心、售后、充值等)由接口内部判定并返回 401「请先登录后再访问」
+- 短信验证码:Redis `yoshop:api:sms:{mobile}` → `{"code":"123456","times":5}`,TTL 1800 秒;
+  发送通道未接入(`/api/captcha/sms` 在下一批),接入前可通过该键直接写入验证码联调
 - 出参:驼峰,沿用 store / admin 口径;列表分页结构 `{total, perPage, currentPage, lastPage, data}`
-- 一键验证:`python tools/test_api_batch1.py`(需 18080 在跑,52 项断言)
+- 一键验证:
+  - `python tools/test_api_batch1.py`(首页 / 店铺 / 地区 / 文章 / 商品 / 营销,52 项)
+  - `python tools/test_api_batch2.py`(购物车 / 结算下单 / 订单 / 评价 / 地址,97 项)
+  - `python tools/test_api_batch3.py`(用户中心 / 优惠券 / 明细 / 售后 / 充值 / 上传,99 项)
+- 已实现的骨架:第三方支付(微信 / 支付宝下单、交易查询)与短信发送,未配置时返回明确文案;
+  充值支付成功结算 `ApiRechargeService.paySuccess` 已就绪,下一批支付回调直接复用