Просмотр исходного кода

fix: 授权按钮改用API请求获取auth URL后重定向; 容器日志目录修复; deploy.sh加logs目录创建

alphaH 1 неделя назад
Родитель
Сommit
02a98133aa

+ 2 - 2
.codegraph/daemon.pid

@@ -1,6 +1,6 @@
 {
 {
-  "pid": 36300,
+  "pid": 24184,
   "version": "0.9.9",
   "version": "0.9.9",
   "socketPath": "\\\\.\\pipe\\codegraph-9515f3f05b4112da",
   "socketPath": "\\\\.\\pipe\\codegraph-9515f3f05b4112da",
-  "startedAt": 1782458616450
+  "startedAt": 1782720324347
 }
 }

+ 3 - 0
deploy.sh

@@ -4,6 +4,9 @@ cd /tmp/docker-build
 cp /opt/payment/app.jar ./app.jar
 cp /opt/payment/app.jar ./app.jar
 cat > Dockerfile << 'ENDOFDOCKERFILE'
 cat > Dockerfile << 'ENDOFDOCKERFILE'
 FROM xjz/java-backend:1.0.0
 FROM xjz/java-backend:1.0.0
+USER root
+RUN mkdir -p /app/logs && chown app:app /app/logs
+USER app
 COPY app.jar /app/app.jar
 COPY app.jar /app/app.jar
 ENDOFDOCKERFILE
 ENDOFDOCKERFILE
 docker build --no-cache -t pay:latest .
 docker build --no-cache -t pay:latest .

BIN
frontend/dist.zip


+ 12 - 0
frontend/src/api/module_payment/enterprise.ts

@@ -178,6 +178,18 @@ const enterpriseApi = {
       method: "post",
       method: "post",
     });
     });
   },
   },
+
+  /**
+   * 获取支付宝授权链接
+   * @param enterpriseId 企业ID
+   */
+  getAuthUrl: (enterpriseId: string) => {
+    return request({
+      url: "/payment/aplipay/redirect",
+      method: "get",
+      params: { enterprise_id: enterpriseId },
+    });
+  },
 };
 };
 
 
 export default enterpriseApi;
 export default enterpriseApi;

+ 14 - 0
frontend/src/api/module_payment/facetoface.ts

@@ -6,6 +6,7 @@ export const ORDER_STATUS_TAG_TYPE: Record<string, string> = {
   SUBMITTED: "",
   SUBMITTED: "",
   MERCHANT_AUDITING: "warning",
   MERCHANT_AUDITING: "warning",
   MERCHANT_CONFIRM: "",
   MERCHANT_CONFIRM: "",
+  PENDING_AUTH: "warning",
   SUCCESS: "success",
   SUCCESS: "success",
   CLOSED: "danger",
   CLOSED: "danger",
 };
 };
@@ -16,6 +17,7 @@ export const ORDER_STATUS_LABEL: Record<string, string> = {
   SUBMITTED: "已提交",
   SUBMITTED: "已提交",
   MERCHANT_AUDITING: "审核中",
   MERCHANT_AUDITING: "审核中",
   MERCHANT_CONFIRM: "等待商家确认",
   MERCHANT_CONFIRM: "等待商家确认",
+  PENDING_AUTH: "待授权",
   SUCCESS: "开通成功",
   SUCCESS: "开通成功",
   CLOSED: "已关闭",
   CLOSED: "已关闭",
 };
 };
@@ -26,6 +28,7 @@ export const ORDER_STATUS_OPTIONS = [
   { label: "已提交", value: "SUBMITTED" },
   { label: "已提交", value: "SUBMITTED" },
   { label: "审核中", value: "MERCHANT_AUDITING" },
   { label: "审核中", value: "MERCHANT_AUDITING" },
   { label: "等待商家确认", value: "MERCHANT_CONFIRM" },
   { label: "等待商家确认", value: "MERCHANT_CONFIRM" },
+  { label: "待授权", value: "PENDING_AUTH" },
   { label: "开通成功", value: "SUCCESS" },
   { label: "开通成功", value: "SUCCESS" },
   { label: "已关闭", value: "CLOSED" },
   { label: "已关闭", value: "CLOSED" },
 ];
 ];
@@ -128,6 +131,17 @@ const facetofaceApi = {
       method: "get",
       method: "get",
     });
     });
   },
   },
+
+  /**
+   * 获取授权链接(重定向到支付宝)
+   */
+  getAuthUrl: (enterpriseId: string) => {
+    return request({
+      url: `/payment/facetoface/auth-url`,
+      method: "get",
+      params: { enterprise_id: enterpriseId },
+    });
+  },
 };
 };
 
 
 // 交易状态标签
 // 交易状态标签

+ 23 - 2
frontend/src/views/module_payment/enterprise/index.vue

@@ -59,11 +59,17 @@
                     @click="handleOpenDetail(scope.row)">
                     @click="handleOpenDetail(scope.row)">
                     详情
                     详情
                   </el-button>
                   </el-button>
-                  <el-button v-if="!isAdmin" v-hasPerm="['module_payment:facetoface:apply']" type="success" size="small" link
-                    :disabled="scope.row.f2f_status && scope.row.f2f_status !== 'CLOSED'"
+                  <el-button v-if="!isAdmin && (!scope.row.f2f_status || scope.row.f2f_status === 'CLOSED')"
+                    v-hasPerm="['module_payment:facetoface:apply']" type="success" size="small" link
                     @click="handleOpenF2fApply(scope.row)">
                     @click="handleOpenF2fApply(scope.row)">
                     开通当面付
                     开通当面付
                   </el-button>
                   </el-button>
+                  <el-button
+                    v-if="scope.row.f2f_status === 'PENDING_AUTH'"
+                    type="warning" size="small" link icon="Link"
+                    @click="handleGetAuthUrl(scope.row)">
+                    授权
+                  </el-button>
                 </template>
                 </template>
               </el-table-column>
               </el-table-column>
             </el-table>
             </el-table>
@@ -558,6 +564,21 @@ async function handleSubmitF2f() {
     f2fSubmitting.value = false;
     f2fSubmitting.value = false;
   }
   }
 }
 }
+
+// ---------- 授权链接 ----------
+async function handleGetAuthUrl(row: any) {
+  try {
+    const res = await EnterpriseAPI.getAuthUrl(row.enterprise_id);
+    const authUrl = res.data?.data;
+    if (authUrl) {
+      window.location.href = authUrl;
+    } else {
+      ElMessage.error("未获取到授权链接");
+    }
+  } catch {
+    ElMessage.error("获取授权链接失败");
+  }
+}
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>

+ 0 - 382
frontend/src/views/module_payment/facetoface/index.vue

@@ -1,382 +0,0 @@
-<template>
-  <div v-loading="pageLoading" class="app-container" :element-loading-text="loadingText">
-    <PageSearch ref="searchRef" :search-config="searchConfig" @query-click="handleQueryClick"
-      @reset-click="handleResetClick" />
-
-    <PageContent ref="contentRef" :content-config="contentConfig">
-      <template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
-        <CrudToolbarLeft text="提交申请" :remove-ids="removeIds" :perm-create="['module_payment:facetoface:apply']">
-          <el-button v-hasPerm="['module_payment:facetoface:apply']" type="primary" icon="Plus"
-            @click="handleOpenApplyDialog">
-            提交申请
-          </el-button>
-        </CrudToolbarLeft>
-
-        <div class="data-table__toolbar--right">
-          <CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
-        </div>
-      </template>
-
-      <template #table="{ data, loading, tableRef, onSelectionChange }">
-        <div class="data-table__content">
-          <el-table :ref="tableRef as any" v-loading="loading" :data="data" height="100%" border
-            @selection-change="onSelectionChange">
-            <template #empty>
-              <el-empty :image-size="80" description="暂无数据" />
-            </template>
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'selection')?.show" type="selection"
-              min-width="55" align="center" />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'batch_no')?.show" key="batch_no"
-              label="事务编号" prop="batch_no" min-width="160" show-overflow-tooltip />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'account')?.show" key="account"
-              label="支付宝账号" prop="account" min-width="180" show-overflow-tooltip />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'merchant_name')?.show" key="merchant_name"
-              label="商户名称" prop="merchant_name" min-width="120" show-overflow-tooltip />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'shop_name')?.show" key="shop_name"
-              label="店铺名称" prop="shop_name" min-width="120" show-overflow-tooltip />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'order_status')?.show" key="order_status"
-              label="状态" prop="order_status" min-width="120">
-              <template #default="scope">
-                <el-tag :type="ORDER_STATUS_TAG_TYPE[scope.row.order_status]">
-                  {{ ORDER_STATUS_LABEL[scope.row.order_status] || scope.row.order_status }}
-                </el-tag>
-              </template>
-            </el-table-column>
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'rate')?.show" key="rate"
-              label="费率" prop="rate" min-width="80" />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'reject_reason')?.show" key="reject_reason"
-              label="驳回原因" prop="reject_reason" min-width="150" show-overflow-tooltip />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'confirm_url')?.show" key="confirm_url"
-              label="确认链接" prop="confirm_url" min-width="100">
-              <template #default="scope">
-                <el-button v-if="scope.row.confirm_url" type="primary" size="small" link
-                  @click="handleCopyUrl(scope.row.confirm_url)">
-                  复制链接
-                </el-button>
-                <span v-else>-</span>
-              </template>
-            </el-table-column>
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'created_time')?.show" key="created_time"
-              label="创建时间" prop="created_time" min-width="160" sortable />
-            <el-table-column v-if="contentCols.find((col) => col.prop === 'operation')?.show" fixed="right" label="操作"
-              align="center" min-width="180">
-              <template #default="scope">
-                <el-button v-hasPerm="['module_payment:facetoface:list']" type="info" size="small" link icon="View"
-                  @click="handleOpenDetailDialog(scope.row.id)">
-                  详情
-                </el-button>
-                <el-button v-hasPerm="['module_payment:facetoface:query']" type="primary" size="small" link
-                  icon="Refresh"
-                  :disabled="scope.row.order_status === 'SUCCESS' || scope.row.order_status === 'CLOSED'"
-                  @click="handleQueryStatus(scope.row.id)">
-                  查询状态
-                </el-button>
-              </template>
-            </el-table-column>
-          </el-table>
-        </div>
-      </template>
-    </PageContent>
-
-    <!-- 申请表单弹窗 -->
-    <EnhancedDialog v-model="applyDialogVisible" title="提交当面付开通申请" width="600px" @close="handleCloseApplyDialog">
-      <el-form ref="applyFormRef" :model="applyForm" :rules="applyRules" label-width="120px">
-        <el-form-item label="商户名称" prop="merchant_name">
-          <el-input v-model="applyForm.merchant_name" placeholder="请输入商户名称" />
-        </el-form-item>
-        <el-form-item label="店铺名称" prop="shop_name">
-          <el-input v-model="applyForm.shop_name" placeholder="请输入店铺名称" />
-        </el-form-item>
-        <el-form-item label="店铺地址" prop="shop_address">
-          <el-input v-model="applyForm.shop_address" placeholder="请输入店铺地址" />
-        </el-form-item>
-        <el-form-item label="经营类目" prop="mcc_code">
-          <el-select v-model="applyForm.mcc_code" placeholder="请搜索选择经营类目" filterable clearable
-            style="width: 100%">
-            <el-option v-for="item in MCC_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="费率" prop="rate">
-          <el-input v-model="applyForm.rate" placeholder="如 0.006 表示 0.6%" />
-        </el-form-item>
-        <el-form-item label="营业执照号" prop="business_license_no">
-          <el-input v-model="applyForm.business_license_no" placeholder="请输入营业执照号" />
-        </el-form-item>
-        <el-form-item label="联系手机号" prop="business_license_mobile">
-          <el-input v-model="applyForm.business_license_mobile" placeholder="请输入联系手机号" />
-        </el-form-item>
-        <el-form-item label="同时获取授权" prop="sign_and_auth">
-          <el-switch v-model="applyForm.sign_and_auth" />
-        </el-form-item>
-        <el-form-item label="备注" prop="remark">
-          <el-input v-model="applyForm.remark" type="textarea" :rows="2" placeholder="备注信息" />
-        </el-form-item>
-      </el-form>
-
-      <template #footer>
-        <div class="dialog-footer">
-          <el-button type="primary" :loading="isSubmitting" @click="handleSubmitApply">提交</el-button>
-          <el-button @click="handleCloseApplyDialog">取消</el-button>
-        </div>
-      </template>
-    </EnhancedDialog>
-
-    <!-- 详情弹窗 -->
-    <EnhancedDialog v-model="detailDialogVisible" title="申请单详情" width="600px">
-      <el-descriptions v-if="detailData" :column="1" border>
-        <el-descriptions-item label="事务编号">{{ detailData.batch_no || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="申请单号">{{ detailData.order_no || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="商户支付宝账号">{{ detailData.account || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="状态">
-          <el-tag :type="ORDER_STATUS_TAG_TYPE[detailData.order_status]">
-            {{ ORDER_STATUS_LABEL[detailData.order_status] || detailData.order_status }}
-          </el-tag>
-        </el-descriptions-item>
-        <el-descriptions-item label="商户名称">{{ detailData.merchant_name || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="店铺名称">{{ detailData.shop_name || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="店铺地址">{{ detailData.shop_address || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="MCC码">{{ detailData.mcc_code || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="费率">{{ detailData.rate || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="营业执照号">{{ detailData.business_license_no || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="联系手机号">{{ detailData.business_license_mobile || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="同时获取授权">{{ detailData.sign_and_auth ? '是' : '否' }}</el-descriptions-item>
-        <el-descriptions-item v-if="detailData.confirm_url" label="确认链接">
-          <el-link type="primary" :href="detailData.confirm_url" target="_blank">{{ detailData.confirm_url }}</el-link>
-        </el-descriptions-item>
-        <el-descriptions-item v-if="detailData.app_auth_token" label="授权Token">
-          {{ detailData.app_auth_token }}
-        </el-descriptions-item>
-        <el-descriptions-item v-if="detailData.reject_reason" label="驳回原因">
-          <span style="color: #f56c6c">{{ detailData.reject_reason }}</span>
-        </el-descriptions-item>
-        <el-descriptions-item v-if="detailData.remark" label="备注">{{ detailData.remark }}</el-descriptions-item>
-        <el-descriptions-item label="查询次数">{{ detailData.query_count }}</el-descriptions-item>
-        <el-descriptions-item label="最后查询时间">{{ detailData.last_query_time || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="创建时间">{{ detailData.created_time }}</el-descriptions-item>
-        <el-descriptions-item label="更新时间">{{ detailData.updated_time }}</el-descriptions-item>
-      </el-descriptions>
-
-      <template #footer>
-        <el-button type="primary" @click="detailDialogVisible = false">确定</el-button>
-      </template>
-    </EnhancedDialog>
-  </div>
-</template>
-
-<script setup lang="ts">
-defineOptions({
-  name: "Facetoface",
-  inheritAttrs: false,
-});
-
-import FacetofaceAPI, {
-  ORDER_STATUS_TAG_TYPE,
-  ORDER_STATUS_LABEL,
-  ORDER_STATUS_OPTIONS,
-} from "@/api/module_payment/facetoface";
-import { MCC_OPTIONS } from "@/constants/mcc-codes";
-import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
-import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
-import PageSearch from "@/components/CURD/PageSearch.vue";
-import PageContent from "@/components/CURD/PageContent.vue";
-import EnhancedDialog from "@/components/CURD/EnhancedDialog.vue";
-import type { ISearchConfig, IContentConfig } from "@/components/CURD/types";
-import { useCrudList } from "@/components/CURD/useCrudList";
-import { useLoadingAction } from "@/composables/useLoadingAction";
-import { ElMessage, type FormInstance, type FormRules } from "element-plus";
-import { ref, reactive } from "vue";
-
-const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
-const { pageLoading, loadingText, execute: loadingExecute } = useLoadingAction();
-
-// ---------- 搜索配置 ----------
-const searchConfig = reactive<ISearchConfig>({
-  permPrefix: "module_payment:facetoface",
-  colon: true,
-  isExpandable: true,
-  showNumber: 3,
-  form: { labelWidth: "auto" },
-  formItems: [
-    {
-      prop: "merchant_name",
-      label: "商户名称",
-      type: "input",
-      attrs: { placeholder: "请输入商户名称", clearable: true },
-    },
-    {
-      prop: "shop_name",
-      label: "店铺名称",
-      type: "input",
-      attrs: { placeholder: "请输入店铺名称", clearable: true },
-    },
-    {
-      prop: "order_status",
-      label: "状态",
-      type: "select",
-      options: ORDER_STATUS_OPTIONS,
-      attrs: { placeholder: "请选择状态", clearable: true, style: { width: "167.5px" } },
-    },
-    {
-      prop: "created_time",
-      label: "创建时间",
-      type: "date-picker",
-      attrs: {
-        type: "datetimerange",
-        rangeSeparator: "至",
-        startPlaceholder: "开始日期",
-        endPlaceholder: "结束日期",
-        format: "YYYY-MM-DD HH:mm:ss",
-        valueFormat: "YYYY-MM-DD HH:mm:ss",
-        style: { width: "340px" },
-      },
-    },
-  ],
-});
-
-// ---------- 表格配置 ----------
-const contentCols = reactive<Array<{ prop?: string; label?: string; show?: boolean }>>([
-  { prop: "selection", label: "选择框", show: false },
-  { prop: "batch_no", label: "事务编号", show: true },
-  { prop: "account", label: "支付宝账号", show: true },
-  { prop: "merchant_name", label: "商户名称", show: true },
-  { prop: "shop_name", label: "店铺名称", show: true },
-  { prop: "order_status", label: "状态", show: true },
-  { prop: "rate", label: "费率", show: true },
-  { prop: "reject_reason", label: "驳回原因", show: false },
-  { prop: "confirm_url", label: "确认链接", show: true },
-  { prop: "created_time", label: "创建时间", show: true },
-  { prop: "operation", label: "操作", show: true },
-]);
-
-const contentConfig = reactive<IContentConfig<any>>({
-  permPrefix: "module_payment:facetoface",
-  pk: "id",
-  cols: contentCols as IContentConfig["cols"],
-  hideColumnFilter: false,
-  toolbar: [],
-  defaultToolbar: ["refresh", "filter"],
-  pagination: true,
-  request: { page_no: "page_no", page_size: "page_size" },
-  indexAction: async (params) => {
-    const search: Record<string, any> = {};
-    if (params.merchant_name) search.merchant_name = params.merchant_name;
-    if (params.shop_name) search.shop_name = params.shop_name;
-    if (params.order_status) search.order_status = params.order_status;
-    if (params.created_time && params.created_time.length === 2) {
-      search.start_time = params.created_time[0];
-      search.end_time = params.created_time[1];
-    }
-    const res = await FacetofaceAPI.list(params.page_no, params.page_size, search);
-    return {
-      list: res.data?.data?.items || res.data?.data?.list || [],
-      total: res.data?.data?.total || 0,
-    };
-  },
-});
-
-// ---------- 申请表单 ----------
-const applyDialogVisible = ref(false);
-const applyFormRef = ref<FormInstance>();
-const isSubmitting = ref(false);
-
-const applyForm = reactive({
-  merchant_name: "",
-  shop_name: "",
-  shop_address: "",
-  mcc_code: "",
-  rate: "",
-  business_license_no: "",
-  business_license_mobile: "",
-  sign_and_auth: false,
-  remark: "",
-});
-
-const applyRules = reactive<FormRules>({
-  merchant_name: [{ required: true, message: "请输入商户名称", trigger: "blur" }],
-  shop_name: [{ required: true, message: "请输入店铺名称", trigger: "blur" }],
-});
-
-function handleOpenApplyDialog() {
-  Object.assign(applyForm, {
-    merchant_name: "",
-    shop_name: "",
-    shop_address: "",
-    mcc_code: "",
-    rate: "",
-    business_license_no: "",
-    business_license_mobile: "",
-    sign_and_auth: false,
-    remark: "",
-  });
-  applyDialogVisible.value = true;
-}
-
-function handleCloseApplyDialog() {
-  applyDialogVisible.value = false;
-  applyFormRef.value?.resetFields();
-}
-
-async function handleSubmitApply() {
-  if (!applyFormRef.value) return;
-  await applyFormRef.value.validate();
-
-  isSubmitting.value = true;
-  try {
-    const submitData: Record<string, any> = {
-      merchant_name: applyForm.merchant_name,
-      shop_name: applyForm.shop_name,
-      sign_and_auth: applyForm.sign_and_auth,
-    };
-    if (applyForm.shop_address) submitData.shop_address = applyForm.shop_address;
-    if (applyForm.mcc_code) submitData.mcc_code = applyForm.mcc_code;
-    if (applyForm.rate) submitData.rate = applyForm.rate;
-    if (applyForm.business_license_no) submitData.business_license_no = applyForm.business_license_no;
-    if (applyForm.business_license_mobile) submitData.business_license_mobile = applyForm.business_license_mobile;
-    if (applyForm.remark) submitData.remark = applyForm.remark;
-
-    await FacetofaceAPI.apply(submitData as any);
-    ElMessage.success("当面付开通申请已提交");
-    handleCloseApplyDialog();
-    refreshList();
-  } catch (e: any) {
-    ElMessage.error(e?.response?.data?.msg || "提交失败");
-  } finally {
-    isSubmitting.value = false;
-  }
-}
-
-// ---------- 详情弹窗 ----------
-const detailDialogVisible = ref(false);
-const detailData = ref<any>(null);
-
-async function handleOpenDetailDialog(orderId: number) {
-  try {
-    const res = await FacetofaceAPI.detail(orderId);
-    detailData.value = res.data?.data;
-    detailDialogVisible.value = true;
-  } catch {
-    ElMessage.error("查询详情失败");
-  }
-}
-
-// ---------- 查询状态 ----------
-async function handleQueryStatus(orderId: number) {
-  await loadingExecute({
-    loadingText: "正在查询申请单状态...",
-    action: () => FacetofaceAPI.queryStatus(orderId),
-    onSuccess: () => {
-      ElMessage.success("状态已刷新");
-      refreshList();
-    },
-  });
-}
-
-// ---------- 复制链接 ----------
-function handleCopyUrl(url: string) {
-  navigator.clipboard.writeText(url).then(() => {
-    ElMessage.success("链接已复制到剪贴板");
-  }).catch(() => {
-    ElMessage.error("复制失败");
-  });
-}
-</script>

+ 2 - 0
java/Dockerfile

@@ -7,6 +7,8 @@ WORKDIR /app
 
 
 RUN addgroup -S app && adduser -S app -G app
 RUN addgroup -S app && adduser -S app -G app
 
 
+RUN mkdir -p /app/logs && chown app:app /app/logs
+
 COPY target/payment-platform-1.0.0.jar app.jar
 COPY target/payment-platform-1.0.0.jar app.jar
 
 
 EXPOSE 8081
 EXPOSE 8081

+ 36 - 4
java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java

@@ -148,6 +148,32 @@ public class AlipayClientFactory {
         return paymentAlipayConfig;
         return paymentAlipayConfig;
     }
     }
 
 
+    /**
+     * 按企业 + 业务类型解析 app_id(与 {@link #getClient} 同一条解析链)
+     */
+    public String getAppId(String enterpriseId, String bizType) {
+        if (enterpriseId != null && !enterpriseId.isBlank()) {
+            EnterpriseEntity ent = enterpriseMapper.selectByEnterpriseIdIgnoreTenant(enterpriseId);
+            if (ent != null && ent.getServiceProviderId() != null) {
+                Long spId = ent.getServiceProviderId();
+                // 1. 尝试业务专属凭证
+                if (bizType != null) {
+                    ServiceProviderProfileEntity profile = getProfileEntity(spId, bizType);
+                    if (profile != null && profile.getAppId() != null) {
+                        return profile.getAppId();
+                    }
+                }
+                // 2. 回退到服务商默认凭证
+                ServiceProviderEntity sp = serviceProviderMapper.selectById(spId);
+                if (sp != null && sp.getAppId() != null) {
+                    return sp.getAppId();
+                }
+            }
+        }
+        // 3. fallback 到 yml 配置
+        return paymentAlipayConfig.getAppId();
+    }
+
     // ==================== 内部方法 ====================
     // ==================== 内部方法 ====================
 
 
     /** 按服务商 + 业务类型获取专属客户端,无配置返回 null */
     /** 按服务商 + 业务类型获取专属客户端,无配置返回 null */
@@ -156,15 +182,21 @@ public class AlipayClientFactory {
         AlipayClient cached = profileClients.get(cacheKey);
         AlipayClient cached = profileClients.get(cacheKey);
         if (cached != null) return cached;
         if (cached != null) return cached;
 
 
-        ServiceProviderProfileEntity profile = profileMapper.selectOne(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ServiceProviderProfileEntity>()
-                        .eq(ServiceProviderProfileEntity::getServiceProviderId, spId)
-                        .eq(ServiceProviderProfileEntity::getBizType, bizType));
+        ServiceProviderProfileEntity profile = getProfileEntity(spId, bizType);
         if (profile == null) return null;
         if (profile == null) return null;
 
 
         return profileClients.computeIfAbsent(cacheKey, k -> createClientForProfile(profile));
         return profileClients.computeIfAbsent(cacheKey, k -> createClientForProfile(profile));
     }
     }
 
 
+    /** 按服务商 + 业务类型获取专属凭证实体,无配置返回 null */
+    private ServiceProviderProfileEntity getProfileEntity(Long spId, String bizType) {
+        return profileMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ServiceProviderProfileEntity>()
+                        .eq(ServiceProviderProfileEntity::getServiceProviderId, spId)
+                        .eq(ServiceProviderProfileEntity::getBizType, bizType));
+    }
+
+
     private AlipayClient createClientForProfile(ServiceProviderProfileEntity profile) {
     private AlipayClient createClientForProfile(ServiceProviderProfileEntity profile) {
         AlipayConfig config = buildSdkConfig(
         AlipayConfig config = buildSdkConfig(
                 profile.getAppId(), profile.getAppPrivateKey(), profile.getAlipayPublicKey(),
                 profile.getAppId(), profile.getAppPrivateKey(), profile.getAlipayPublicKey(),

+ 1 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -53,6 +53,7 @@ public class SecurityConfig {
             "/system/notice/available",
             "/system/notice/available",
             "/payment/notify/health",
             "/payment/notify/health",
             "/payment/notify/alipay",
             "/payment/notify/alipay",
+            "/payment/aplipay/auth",
             "/v3/api-docs/**",
             "/v3/api-docs/**",
             "/swagger-ui/**",
             "/swagger-ui/**",
             "/swagger-ui.html",
             "/swagger-ui.html",

+ 64 - 0
java/src/main/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthController.java

@@ -0,0 +1,64 @@
+package com.payment.platform.module.payment.facetoface.controller;
+
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.facetoface.service.FacetofaceService;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 支付宝授权相关接口 — @Controller 统一用 sendRedirect
+ */
+@Slf4j
+@Controller
+@RequestMapping("/payment/aplipay")
+@RequiredArgsConstructor
+public class AlipayAuthController {
+
+    private final FacetofaceService service;
+
+    private static final String FRONTEND_URL = "https://qcsj88888.com/#/payment/enterprise";
+
+    /**
+     * 授权回调 — 支付宝 302 回跳,用 app_auth_code 换 token,完成后重定向回前端
+     */
+    @GetMapping("/auth")
+    public void auth(
+            @RequestParam("app_id") String appId,
+            @RequestParam("source") String source,
+            @RequestParam("state") String state,
+            @RequestParam("app_auth_code") String appAuthCode,
+            HttpServletResponse response) throws IOException {
+
+        String redirect;
+
+        log.info("收到支付宝授权回调: app_id={}, source={}, enterprise_id={}, code_prefix={}",
+                appId, source, state,
+                appAuthCode.length() > 10 ? appAuthCode.substring(0, 10) : appAuthCode);
+
+        try {
+            service.exchangeAppAuthCode(state, appId, appAuthCode);
+            redirect = FRONTEND_URL + "?auth=success";
+        } catch (Exception e) {
+            log.error("授权回调处理失败: enterprise_id={}, error={}", state, e.getMessage());
+            redirect = FRONTEND_URL + "?auth=fail&msg=" + java.net.URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
+        }
+
+        response.sendRedirect(redirect);
+    }
+
+    /**
+     * 生成授权链接 — 返回支付宝授权 URL,由前端完成重定向
+     */
+    @ResponseBody
+    @GetMapping("/redirect")
+    public Result<String> redirectToAlipay(
+            @RequestParam("enterprise_id") String enterpriseId) {
+        return Result.ok(service.getAuthUrl(enterpriseId));
+    }
+}

+ 2 - 0
java/src/main/java/com/payment/platform/module/payment/facetoface/enums/FacetofaceOrderStatus.java

@@ -8,6 +8,8 @@ public enum FacetofaceOrderStatus {
     SUBMITTED("SUBMITTED"),
     SUBMITTED("SUBMITTED"),
     MERCHANT_AUDITING("MERCHANT_AUDITING"),
     MERCHANT_AUDITING("MERCHANT_AUDITING"),
     MERCHANT_CONFIRM("MERCHANT_CONFIRM"),
     MERCHANT_CONFIRM("MERCHANT_CONFIRM"),
+    /** 支付宝侧已确认,等待商户授权 app_auth_token(回调换 token) */
+    PENDING_AUTH("PENDING_AUTH"),
     SUCCESS("SUCCESS"),
     SUCCESS("SUCCESS"),
     CLOSED("CLOSED");
     CLOSED("CLOSED");
 
 

+ 131 - 6
java/src/main/java/com/payment/platform/module/payment/facetoface/service/FacetofaceService.java

@@ -28,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.time.OffsetDateTime;
 import java.time.OffsetDateTime;
 import java.util.*;
 import java.util.*;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
@@ -99,7 +101,7 @@ public class FacetofaceService {
         String lockKey = "f2f:apply:" + dto.getEnterpriseId();
         String lockKey = "f2f:apply:" + dto.getEnterpriseId();
         String lockValue = redisLockUtil.lock(lockKey, 30);
         String lockValue = redisLockUtil.lock(lockKey, 30);
         if (lockValue == null) {
         if (lockValue == null) {
-            throw new BusinessException(400, "该企业正在处理中,请稍后再试");
+            throw new BusinessException(400, "正在处理中,请稍后再试");
         }
         }
         try {
         try {
             // 检查同一企业是否有进行中的申请
             // 检查同一企业是否有进行中的申请
@@ -110,7 +112,7 @@ public class FacetofaceService {
                             .orderByDesc(FacetofaceOrderEntity::getId)
                             .orderByDesc(FacetofaceOrderEntity::getId)
                             .last("LIMIT 1"));
                             .last("LIMIT 1"));
             if (active != null) {
             if (active != null) {
-                throw new BusinessException(400, "该企业已有进行中的当面付申请");
+                throw new BusinessException(400, "已有进行中的当面付申请");
             }
             }
 
 
             // 查询同一企业是否有历史 CLOSED 申请单(用于复用更新而非插新行)
             // 查询同一企业是否有历史 CLOSED 申请单(用于复用更新而非插新行)
@@ -320,8 +322,9 @@ public class FacetofaceService {
                             e.setNextQueryTime(now.plusHours(4));
                             e.setNextQueryTime(now.plusHours(4));
                             break;
                             break;
                         case "MERCHANT_CONFIRM_SUCCESS":
                         case "MERCHANT_CONFIRM_SUCCESS":
-                            e.setOrderStatus(FacetofaceOrderStatus.SUCCESS.getValue());
-                            e.setNextQueryTime(null);
+                            // 支付宝侧已确认,但 app_auth_token 需通过回调授权获得,进入待授权状态
+                            e.setOrderStatus(FacetofaceOrderStatus.PENDING_AUTH.getValue());
+                            e.setNextQueryTime(now.plusHours(4));
                             break;
                             break;
                         case "MERCHANT_APPLY_ORDER_CANCELED":
                         case "MERCHANT_APPLY_ORDER_CANCELED":
                         case "MERCHANT_CONFIRM_TIME_OUT":
                         case "MERCHANT_CONFIRM_TIME_OUT":
@@ -557,10 +560,10 @@ public class FacetofaceService {
                         .orderByDesc(FacetofaceOrderEntity::getId)
                         .orderByDesc(FacetofaceOrderEntity::getId)
                         .last("LIMIT 1"));
                         .last("LIMIT 1"));
         if (order == null) {
         if (order == null) {
-            throw new BusinessException(400, "企业尚未开通当面付或开通未成功");
+            throw new BusinessException(400, "企业尚未开通当面付或开通未成功");
         }
         }
         if (StrUtil.isBlank(order.getAppAuthToken())) {
         if (StrUtil.isBlank(order.getAppAuthToken())) {
-            throw new BusinessException(400, "该企业的当面付授权令牌缺失,请重新开通");
+            throw new BusinessException(400, "当面付授权令牌缺失,请通知企业管理员完成授权");
         }
         }
         return order;
         return order;
     }
     }
@@ -675,6 +678,122 @@ public class FacetofaceService {
         }
         }
     }
     }
 
 
+    // ==================== 授权回调(app_auth_code 换 token) ====================
+
+    /**
+     * 用 app_auth_code 换取 app_auth_token(支付宝授权回调流程)
+     * <p>
+     * 对应 flow: 商户在支付宝商家平台确认授权 → 支付宝 302 回调 → 服务端用 code 换 token → 落库。
+     */
+    @Transactional
+    public void exchangeAppAuthCode(String enterpriseId, String appId, String appAuthCode) {
+        // 1. 查找企业对应的申请单(优先找进行中的非终态单,其次找已关闭旧单)
+        FacetofaceOrderEntity order = mapper.selectOne(
+                new LambdaQueryWrapper<FacetofaceOrderEntity>()
+                        .eq(FacetofaceOrderEntity::getEnterpriseId, enterpriseId)
+                        .notIn(FacetofaceOrderEntity::getOrderStatus,
+                                FacetofaceOrderStatus.SUCCESS.getValue(),
+                                FacetofaceOrderStatus.CLOSED.getValue())
+                        .orderByDesc(FacetofaceOrderEntity::getId)
+                        .last("LIMIT 1"));
+
+        if (order == null) {
+            order = mapper.selectOne(
+                    new LambdaQueryWrapper<FacetofaceOrderEntity>()
+                            .eq(FacetofaceOrderEntity::getEnterpriseId, enterpriseId)
+                            .orderByDesc(FacetofaceOrderEntity::getId)
+                            .last("LIMIT 1"));
+        }
+
+        // 2. 调用支付宝 alipay.open.auth.token.app 用 code 换 token
+        try {
+            AlipayOpenAuthTokenAppModel model = new AlipayOpenAuthTokenAppModel();
+            model.setGrantType("authorization_code");
+            model.setCode(appAuthCode);
+
+            AlipayOpenAuthTokenAppRequest request = new AlipayOpenAuthTokenAppRequest();
+            request.setBizModel(model);
+
+            AlipayOpenAuthTokenAppResponse response = alipayClientFactory
+                    .getClient(enterpriseId, ServiceProviderBizType.FACETOFACE_AGENT.getValue())
+                    .execute(request);
+
+            if (!response.isSuccess()) {
+                throw new BusinessException(400, "换取授权令牌失败: " +
+                        (response.getSubMsg() != null ? response.getSubMsg() : response.getMsg()));
+            }
+
+            log.info("当面付授权回调 - 换取token成功: enterprise_id={}, app_id={}, auth_app_id={}, user_id={}",
+                    enterpriseId, appId, response.getAuthAppId(), response.getUserId());
+
+            // 3. 保存/更新 pay_facetoface_order:授权完成,状态推进到 SUCCESS
+            OffsetDateTime now = OffsetDateTime.now();
+
+            if (order != null) {
+                // 已有记录 → 更新 token,停止轮询,状态迁至 SUCCESS
+                order.setOrderStatus(FacetofaceOrderStatus.SUCCESS.getValue());
+                order.setNextQueryTime(null);
+                order.setAppAuthToken(response.getAppAuthToken());
+                order.setAppRefreshToken(response.getAppRefreshToken());
+                order.setAuthAppId(response.getAuthAppId() != null ? response.getAuthAppId() : appId);
+                order.setUserId(response.getUserId());
+                order.setOpenId(null); // 回调不返回 openId,避免覆盖
+                order.setExpiresIn(response.getExpiresIn());
+                order.setReExpiresIn(response.getReExpiresIn());
+                order.setLastQueryTime(now);
+                order.setSignAndAuth(true);
+                order.setRate("0.6");
+                mapper.updateById(order);
+            } else {
+                // 无历史记录:新建申请单,直接标记 SUCCESS
+                FacetofaceOrderEntity e = new FacetofaceOrderEntity();
+                e.setEnterpriseId(enterpriseId);
+                e.setOrderStatus(FacetofaceOrderStatus.SUCCESS.getValue());
+                e.setAppAuthToken(response.getAppAuthToken());
+                e.setAppRefreshToken(response.getAppRefreshToken());
+                e.setAuthAppId(response.getAuthAppId() != null ? response.getAuthAppId() : appId);
+                e.setUserId(response.getUserId());
+                e.setExpiresIn(response.getExpiresIn());
+                e.setReExpiresIn(response.getReExpiresIn());
+                e.setSignAndAuth(true);
+                e.setRate("0.6");
+                e.setNextQueryTime(null);
+                e.setQueryCount(0);
+                e.setStatus("0");
+                mapper.insert(e);
+            }
+
+        } catch (AlipayApiException ex) {
+            log.error("当面付授权回调 - 换token异常: enterprise_id={}, error={}", enterpriseId, ex.getMessage());
+            throw new BusinessException(400, "换取授权令牌失败: " + ex.getMessage());
+        }
+    }
+
+    // ==================== 授权链接 ====================
+
+    /**
+     * 生成支付宝商户授权链接 — 前端获取后 window.open() 引导商户确认授权
+     * <p>
+     * URL 格式: https://openauth.alipay.com/oauth2/appToAppAuth.htm
+     *          ?app_id={ISV_APP_ID}&redirect_uri={CALLBACK}&state={enterprise_id}
+     */
+    public String getAuthUrl(String enterpriseId) {
+        // 从服务商业务凭证中解析 app_id(与 getClient(enterpriseId, FACETOFACE_AGENT) 同一解析链)
+        String appId = alipayClientFactory.getAppId(
+                enterpriseId, ServiceProviderBizType.FACETOFACE_AGENT.getValue());
+        // 支付宝授权回调地址(商户确认授权后支付宝 302 跳转的目标)
+
+        String encodedRedirectUri = URLEncoder.encode("https://qcsj88888.com/api/v1/payment/aplipay/auth", StandardCharsets.UTF_8);
+
+        String url = "https://openauth.alipay.com/oauth2/appToAppAuth.htm"
+                + "?app_id=" + (appId != null ? appId : "")
+                + "&redirect_uri=" + encodedRedirectUri
+                + "&state=" + enterpriseId;
+
+        log.info("生成授权链接: enterprise_id={}, url={}", enterpriseId, url);
+        return url;
+    }
+
     // ========== private ==========
     // ========== private ==========
 
 
     private FacetofaceOrderEntity requireOrder(Long id) {
     private FacetofaceOrderEntity requireOrder(Long id) {
@@ -721,13 +840,19 @@ public class FacetofaceService {
             FacetofaceOrderStatus.SUBMITTED.getValue(),
             FacetofaceOrderStatus.SUBMITTED.getValue(),
             Set.of(FacetofaceOrderStatus.MERCHANT_AUDITING.getValue(),
             Set.of(FacetofaceOrderStatus.MERCHANT_AUDITING.getValue(),
                    FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue(),
                    FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue(),
+                   FacetofaceOrderStatus.PENDING_AUTH.getValue(),
                    FacetofaceOrderStatus.CLOSED.getValue()),
                    FacetofaceOrderStatus.CLOSED.getValue()),
 
 
             FacetofaceOrderStatus.MERCHANT_AUDITING.getValue(),
             FacetofaceOrderStatus.MERCHANT_AUDITING.getValue(),
             Set.of(FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue(),
             Set.of(FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue(),
+                   FacetofaceOrderStatus.PENDING_AUTH.getValue(),
                    FacetofaceOrderStatus.CLOSED.getValue()),
                    FacetofaceOrderStatus.CLOSED.getValue()),
 
 
             FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue(),
             FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue(),
+            Set.of(FacetofaceOrderStatus.PENDING_AUTH.getValue(),
+                   FacetofaceOrderStatus.CLOSED.getValue()),
+
+            FacetofaceOrderStatus.PENDING_AUTH.getValue(),
             Set.of(FacetofaceOrderStatus.SUCCESS.getValue(),
             Set.of(FacetofaceOrderStatus.SUCCESS.getValue(),
                    FacetofaceOrderStatus.CLOSED.getValue())
                    FacetofaceOrderStatus.CLOSED.getValue())
     );
     );

+ 2 - 0
java/src/main/resources/application.yml

@@ -120,6 +120,8 @@ aliyun:
 
 
 # Logging
 # Logging
 logging:
 logging:
+  file:
+    path: ./logs
   level:
   level:
     com.payment.platform: info
     com.payment.platform: info
     org.springframework.security: info
     org.springframework.security: info

+ 75 - 0
java/src/main/resources/logback-spring.xml

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+
+    <!-- 从 yml 读取日志路径,默认 ./logs -->
+    <springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="./logs"/>
+
+    <!-- ==================== 控制台输出 ==================== -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- ==================== 文件输出 (异步) ==================== -->
+    <!-- 全部日志 -->
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${LOG_PATH}/payment-platform.log</file>
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <!-- 每天滚动,单文件最大 100MB -->
+            <fileNamePattern>${LOG_PATH}/payment-platform.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxFileSize>100MB</maxFileSize>
+            <!-- 保留 30 天 -->
+            <maxHistory>30</maxHistory>
+            <!-- 总大小上限 10GB -->
+            <totalSizeCap>10GB</totalSizeCap>
+        </rollingPolicy>
+    </appender>
+
+    <!-- 错误日志单独输出 -->
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>ERROR</level>
+        </filter>
+        <file>${LOG_PATH}/payment-platform-error.log</file>
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_PATH}/payment-platform-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
+            <maxFileSize>50MB</maxFileSize>
+            <maxHistory>60</maxHistory>
+            <totalSizeCap>5GB</totalSizeCap>
+        </rollingPolicy>
+    </appender>
+
+    <!-- ==================== 异步包装 ==================== -->
+    <!-- 队列满时丢弃 TRACE/DEBUG/INFO 级别,保留 WARN/ERROR -->
+    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="FILE"/>
+        <queueSize>512</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+        <neverBlock>true</neverBlock>
+    </appender>
+
+    <appender name="ASYNC_ERROR_FILE" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="ERROR_FILE"/>
+        <queueSize>256</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+        <neverBlock>true</neverBlock>
+    </appender>
+
+    <!-- ==================== 根 logger ==================== -->
+    <root level="INFO">
+        <appender-ref ref="CONSOLE"/>
+        <appender-ref ref="ASYNC_FILE"/>
+        <appender-ref ref="ASYNC_ERROR_FILE"/>
+    </root>
+
+</configuration>