feat(detail): 参会人表新增 增值税及附加/摘要/现场照片 3 列 + 劳务协议展示, 角色=劳务形式, 费项改名 应发金额/个税税金/实发金额

This commit is contained in:
郭庆泰
2026-08-22 16:27:54 +08:00
parent 2ae4cd3ea5
commit da7c19af6c
47 changed files with 4976 additions and 15 deletions
@@ -38,6 +38,12 @@ public class BizMeetingAttendee extends BaseEntity {
private BigDecimal feePreTax;
private BigDecimal tax;
private BigDecimal fee;
/** 增值税及附加成本 (财务口径: 平台承担的开票税 + 附加税) */
private BigDecimal vatAndSurcharge;
/** 摘要 (备注/说明) */
private String summary;
/** 现场照片 (OSS URLs, 多张用逗号分隔) */
private String onSitePhotos;
/* ===== 审计 + 签字结果 ===== */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date signedAt;
@@ -97,6 +103,12 @@ public class BizMeetingAttendee extends BaseEntity {
public void setTax(BigDecimal tax) { this.tax = tax; }
public BigDecimal getFee() { return fee; }
public void setFee(BigDecimal fee) { this.fee = fee; }
public BigDecimal getVatAndSurcharge() { return vatAndSurcharge; }
public void setVatAndSurcharge(BigDecimal vatAndSurcharge) { this.vatAndSurcharge = vatAndSurcharge; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public String getOnSitePhotos() { return onSitePhotos; }
public void setOnSitePhotos(String onSitePhotos) { this.onSitePhotos = onSitePhotos; }
public Date getSignedAt() { return signedAt; }
public void setSignedAt(Date signedAt) { this.signedAt = signedAt; }
public String getSignedIp() { return signedIp; }
@@ -22,6 +22,9 @@
<result property="feePreTax" column="fee_pre_tax" />
<result property="tax" column="tax" />
<result property="fee" column="fee" />
<result property="vatAndSurcharge" column="vat_and_surcharge" />
<result property="summary" column="summary" />
<result property="onSitePhotos" column="on_site_photos" />
<result property="signedAt" column="signed_at" />
<result property="signedIp" column="signed_ip" />
<result property="handsign" column="handsign" />
@@ -49,10 +52,13 @@
<insert id="insertWithProfile" parameterType="BizMeetingAttendee" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_attendee(meeting_id, user_id, name, phone, work_unit, department, title,
id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name,
id_card_attachments, labor_form, fee_pre_tax, tax, fee, create_by, create_time)
id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos,
create_by, create_time)
values(#{meetingId}, #{userId}, #{name}, #{phone}, #{workUnit}, #{department}, #{title},
#{idCard}, #{bankCard}, #{bankName}, #{bankBranch}, #{bankRegion}, #{bankAddress}, #{accountName},
#{idCardAttachments}, #{laborForm}, #{feePreTax}, #{tax}, #{fee}, #{createBy}, sysdate())
#{idCardAttachments}, #{laborForm}, #{feePreTax}, #{tax}, #{fee},
#{vatAndSurcharge}, #{summary}, #{onSitePhotos},
#{createBy}, sysdate())
</insert>
<!-- 批量插入参会人 (BizMeetingController.add 调用) -->
<insert id="insertBatch">
@@ -83,6 +89,9 @@
<if test="feePreTax != null">fee_pre_tax = #{feePreTax},</if>
<if test="tax != null">tax = #{tax},</if>
<if test="fee != null">fee = #{fee},</if>
<if test="vatAndSurcharge != null">vat_and_surcharge = #{vatAndSurcharge},</if>
<if test="summary != null">summary = #{summary},</if>
<if test="onSitePhotos != null">on_site_photos = #{onSitePhotos},</if>
update_by = #{updateBy},
update_time = sysdate()
</set>
@@ -125,15 +134,15 @@
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
</delete>
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where meeting_id = #{meetingId}
</select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where user_id = #{userId}
</select>
<select id="selectById" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where id = #{id}
</select>
<!--
+4
View File
@@ -0,0 +1,4 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="uts">
// #ifdef APP-ANDROID || APP-HARMONY
let firstBackTime = 0
// #endif
onLaunch(() => {
console.log('App Launch')
})
onAppShow(() => {
console.log('App Show')
})
onAppHide(() => {
console.log('App Hide')
})
// #ifdef APP-ANDROID || APP-HARMONY
onLastPageBackPress(() => {
console.log('App LastPageBackPress')
if (firstBackTime == 0) {
uni.showToast({
title: '再按一次退出应用',
position: 'bottom',
})
firstBackTime = Date.now()
setTimeout(() => {
firstBackTime = 0
}, 2000)
} else if (Date.now() - firstBackTime < 2000) {
firstBackTime = Date.now()
uni.exit()
}
})
onExit(() => {
console.log('App Exit')
})
// #endif
</script>
<style>
/*每个页面公共css */
.uni-row {
flex-direction: row;
}
.uni-column {
flex-direction: column;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
/**
* 共享相机 composable (H5 only)
*
* 用法: const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } = useCamera('my-video-id')
*
* - 自动在 onMounted 启动摄像头
* - 自动在 onBeforeUnmount 停止摄像头
* - 返回 ref 和方法,业务页只需负责 UI 布局
*/
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { openCamera, stopCamera, captureFrame, describeCameraError, type Facing } from '@/utils/camera'
export function useCamera(videoId: string) {
const facing = ref<Facing>('environment')
const captured = ref<string>('')
const errorMsg = ref<string>('')
function getVideoEl(): HTMLVideoElement | null {
return document.getElementById(videoId) as HTMLVideoElement | null
}
async function startCamera() {
errorMsg.value = ''
const video = getVideoEl()
if (!video) {
errorMsg.value = '视频元素未找到'
return
}
try {
await openCamera(video, facing.value)
} catch (err) {
errorMsg.value = describeCameraError(err)
console.error('[camera]', err)
}
}
function flipCamera() {
facing.value = facing.value === 'environment' ? 'user' : 'environment'
startCamera()
}
async function takePhoto() {
const video = getVideoEl()
if (!video) return
try {
captured.value = captureFrame(video)
} catch (err) {
uni.showToast({
title: (err as Error).message || '截图失败',
icon: 'none',
})
}
}
function retake() {
captured.value = ''
}
async function confirm() {
// POC: 假上传 + loading 给用户完整仪式感
uni.showLoading({ title: '上传中...' })
await new Promise<void>((resolve) => setTimeout(resolve, 800))
uni.hideLoading()
uni.showToast({
title: '已保存 (POC 未上传)',
icon: 'none',
duration: 1500,
})
captured.value = ''
}
onMounted(() => {
startCamera()
})
onBeforeUnmount(() => {
const video = getVideoEl()
if (video) {
stopCamera(video)
}
})
return {
facing,
captured,
errorMsg,
startCamera,
flipCamera,
takePhoto,
retake,
confirm,
}
}
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title>取景拍摄</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<style>
/* 路由切换瞬间避免白屏 */
html, body {
margin: 0;
padding: 0;
background: #0d0d0d;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
<script type="module" src="/main"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import App from './App.uvue'
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
+82
View File
@@ -0,0 +1,82 @@
{
"name": "ry-h5",
"appid": "__UNI__F1A462A",
"description": "证件/会议拍摄预研 (H5)",
"versionName": "1.0.0",
"versionCode": "100",
"uni-app-x": {},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"h5": {
"title": "证件/会议拍摄",
"router": {
"mode": "hash",
"base": "/camera/"
},
"publicPath": "/camera/",
"devServer": {
"https": false,
"port": 8090,
"disableHostCheck": true,
"publicPath": "/camera/"
},
"sdkConfigs": {},
"optimization": {
"treeShaking": {
"enable": true
}
}
},
"app": {
"distribute": {
"icons": {
"android": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
}
}
}
},
"app-android": {
"distribute": {
"modules": {},
"icons": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
},
"splashScreens": {
"default": {}
}
}
},
"app-ios": {
"distribute": {
"modules": {},
"icons": {},
"splashScreens": {}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
# nginx 配置示例 — 把这个 location 块贴进你已有的 HTTPS server { ... } 里
#
# 打包:
# HBuilderX → 发行 → 网站-手机H5
# 产物: unpackage/dist/build/h5/
#
# 部署:
# sudo cp -r unpackage/dist/build/h5/ /var/www/ry-h5/
#
# 真机访问:
# https://<你的域名或IP>/camera/
# ========== 单 location 块(粘到现有 server 里) ==========
location /camera/ {
alias /var/www/ry-h5/;
index index.html;
try_files $uri $uri/ /camera/index.html;
# 长缓存静态资源(JS/CSS/SVG/PNG)
location ~* ^/camera/static/.*\.(js|css|svg|png|jpg|jpeg|gif|woff2?)$ {
alias /var/www/ry-h5/static/;
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
}
# ========== 完整 server 块参考(若你想独立跑一个 server) ==========
# HTTPS(用你已有的证书路径替换)
# server {
# listen 443 ssl;
# server_name your-domain.com;
#
# ssl_certificate /etc/nginx/ssl/your-cert.pem;
# ssl_certificate_key /etc/nginx/ssl/your-cert-key.pem;
#
# location /camera/ {
# alias /var/www/ry-h5/;
# index index.html;
# try_files $uri $uri/ /camera/index.html;
# }
# }
+38
View File
@@ -0,0 +1,38 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "取景拍摄",
"navigationStyle": "custom",
"backgroundColor": "#0d0d0d"
}
},
{
"path": "pages/signin/index",
"style": {
"navigationBarTitleText": "签到表取景",
"navigationStyle": "custom",
"backgroundColor": "#000000",
"disableScroll": true
}
},
{
"path": "pages/panorama/index",
"style": {
"navigationBarTitleText": "全景取景",
"navigationStyle": "custom",
"backgroundColor": "#000000",
"disableScroll": true
}
}
],
"globalStyle": {
"navigationStyle": "custom",
"navigationBarTextStyle": "white",
"navigationBarTitleText": "取景拍摄",
"navigationBarBackgroundColor": "#000000",
"backgroundColor": "#0d0d0d"
},
"uniIdRouter": {}
}
+154
View File
@@ -0,0 +1,154 @@
<template>
<view class="home">
<view class="hero">
<text class="hero-title">取景拍摄</text>
<text class="hero-subtitle">选择拍摄场景</text>
</view>
<view class="list">
<view class="card" @click="goSignin">
<view class="card-icon-wrap">
<text class="card-icon">📄</text>
</view>
<view class="card-body">
<text class="card-title">签到表拍摄</text>
<text class="card-desc">A4 竖版 · 框宽 90% · 适合签到表 / 会议材料</text>
</view>
<text class="card-arrow"></text>
</view>
<view class="card" @click="goPanorama">
<view class="card-icon-wrap">
<text class="card-icon">📷</text>
</view>
<view class="card-body">
<text class="card-title">全景照片拍摄</text>
<text class="card-desc">16:9 横版 · 框宽 80% · 适合合影 / 全景</text>
</view>
<text class="card-arrow"></text>
</view>
</view>
<view class="footer">
<text class="footer-text">POC 阶段 · 真机测试需 HTTPS</text>
</view>
</view>
</template>
<script setup lang="uts">
function goSignin() {
uni.navigateTo({ url: '/pages/signin/index' })
}
function goPanorama() {
uni.navigateTo({ url: '/pages/panorama/index' })
}
</script>
<style>
.home {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #0d0d0d;
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
overflow-y: auto;
}
.hero {
padding: 80rpx 40rpx 40rpx;
}
.hero-title {
display: block;
color: #FFD600;
font-size: 56rpx;
font-weight: bold;
margin-bottom: 12rpx;
}
.hero-subtitle {
display: block;
color: #999;
font-size: 28rpx;
}
.list {
display: flex;
flex-direction: column;
padding: 0 40rpx;
}
.card {
display: flex;
flex-direction: row;
align-items: center;
padding: 40rpx 32rpx;
margin-bottom: 24rpx;
background: rgba(255, 255, 255, 0.06);
border-radius: 20rpx;
border: 2rpx solid rgba(255, 214, 0, 0.25);
}
.card:active {
background: rgba(255, 214, 0, 0.12);
border-color: rgba(255, 214, 0, 0.6);
}
.card-icon-wrap {
width: 100rpx;
height: 100rpx;
border-radius: 24rpx;
background: rgba(255, 214, 0, 0.15);
display: flex;
justify-content: center;
align-items: center;
margin-right: 24rpx;
}
.card-icon {
font-size: 52rpx;
}
.card-body {
flex: 1;
display: flex;
flex-direction: column;
}
.card-title {
color: #fff;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 8rpx;
}
.card-desc {
color: #999;
font-size: 24rpx;
line-height: 1.4;
}
.card-arrow {
color: #FFD600;
font-size: 56rpx;
margin-left: 16rpx;
font-weight: bold;
}
.footer {
margin-top: auto;
padding: 60rpx 40rpx 40rpx;
text-align: center;
}
.footer-text {
color: #666;
font-size: 22rpx;
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<view class="camera-page">
<!-- 顶部导航 -->
<view class="topbar">
<view class="back-btn" @click="goBack">
<text class="back-icon"></text>
</view>
<text class="topbar-title">全景取景 (16:9)</text>
<view class="placeholder" />
</view>
<!-- 视频 + overlay -->
<view class="viewport">
<video
id="panorama-video"
class="video"
autoplay
muted
playsinline
/>
<image
class="frame panorama-frame"
src="/static/overlay/panorama.svg"
mode="widthFix"
/>
<view v-if="errorMsg" class="error-layer">
<text class="error-text">{{ errorMsg }}</text>
<button class="retry-btn" @click="startCamera">重试</button>
</view>
</view>
<!-- 底部控制条 -->
<view class="controls">
<button class="ctrl-btn" @click="flipCamera">
<text class="ctrl-icon">⟲</text>
<text class="ctrl-label">翻转</text>
</button>
<view class="shutter" @click="takePhoto">
<view class="shutter-inner" />
</view>
<view class="ctrl-btn-placeholder" />
</view>
<!-- 拍照预览层 -->
<view v-if="captured" class="preview">
<image
class="preview-img"
:src="captured"
mode="aspectFit"
/>
<view class="preview-actions">
<button class="preview-btn preview-btn-cancel" @click="retake">
<text>重拍</text>
</button>
<button class="preview-btn preview-btn-confirm" @click="confirm">
<text>使用</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { useCamera } from '@/composables/useCamera'
const videoId = 'panorama-video'
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
useCamera(videoId)
function goBack() {
uni.navigateBack()
}
</script>
<style>
.camera-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 3;
}
/* ========== 顶部导航 ========== */
.topbar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
padding-top: calc(20rpx + env(safe-area-inset-top));
background: rgba(0, 0, 0, 0.7);
}
.back-btn {
width: 80rpx;
height: 80rpx;
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
color: #fff;
font-size: 60rpx;
font-weight: bold;
}
.topbar-title {
color: #fff;
font-size: 32rpx;
font-weight: bold;
}
.placeholder {
width: 80rpx;
}
/* ========== 视频取景区 ========== */
.viewport {
position: relative;
flex: 1;
overflow: hidden;
background: transparent;
display: flex;
justify-content: center;
align-items: center;
}
.video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.frame {
position: relative;
z-index: 1;
pointer-events: none;
}
/* 16:9 框: 宽度 80% 视口,SVG 内置 9:16 比例 (竖屏显示) */
.panorama-frame {
width: 80%;
height: auto;
}
/* ========== 错误层 ========== */
.error-layer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx;
background: rgba(0, 0, 0, 0.75);
border-radius: 16rpx;
max-width: 80%;
z-index: 2;
}
.error-text {
color: #fff;
font-size: 28rpx;
text-align: center;
margin-bottom: 24rpx;
line-height: 1.5;
}
.retry-btn {
padding: 12rpx 48rpx;
background: #FFD600;
color: #000;
border-radius: 32rpx;
font-size: 26rpx;
font-weight: bold;
}
/* ========== 底部控制 ========== */
.controls {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
padding: 40rpx 60rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.7);
}
.ctrl-btn {
display: flex;
flex-direction: column;
align-items: center;
background: transparent;
min-width: 120rpx;
}
.ctrl-icon {
color: #fff;
font-size: 40rpx;
line-height: 1;
margin-bottom: 8rpx;
}
.ctrl-label {
color: #ccc;
font-size: 22rpx;
}
.ctrl-btn-placeholder {
min-width: 120rpx;
}
.shutter {
width: 144rpx;
height: 144rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
border: 6rpx solid #fff;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 0 0 4rpx rgba(0, 0, 0, 0.3);
}
.shutter:active {
background: rgba(255, 255, 255, 0.4);
transform: scale(0.95);
}
.shutter-inner {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: #fff;
}
/* ========== 预览层 ========== */
.preview {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 100;
}
.preview-img {
width: 100%;
flex: 1;
}
.preview-actions {
display: flex;
flex-direction: row;
justify-content: space-around;
width: 100%;
padding: 40rpx 60rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.preview-btn {
flex: 1;
margin: 0 20rpx;
padding: 24rpx 0;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: bold;
}
.preview-btn-cancel {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.preview-btn-confirm {
background: #FFD600;
color: #000;
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<view class="camera-page">
<!-- 顶部导航 -->
<view class="topbar">
<view class="back-btn" @click="goBack">
<text class="back-icon"></text>
</view>
<text class="topbar-title">签到表取景 (A4)</text>
<view class="placeholder" />
</view>
<!-- 视频 + overlay -->
<view class="viewport">
<video
id="signin-video"
class="video"
autoplay
muted
playsinline
/>
<image
class="frame signin-frame"
src="/static/overlay/signin.svg"
mode="widthFix"
/>
<view v-if="errorMsg" class="error-layer">
<text class="error-text">{{ errorMsg }}</text>
<button class="retry-btn" @click="startCamera">重试</button>
</view>
</view>
<!-- 底部控制条 -->
<view class="controls">
<button class="ctrl-btn" @click="flipCamera">
<text class="ctrl-icon">⟲</text>
<text class="ctrl-label">翻转</text>
</button>
<view class="shutter" @click="takePhoto">
<view class="shutter-inner" />
</view>
<view class="ctrl-btn-placeholder" />
</view>
<!-- 拍照预览层 -->
<view v-if="captured" class="preview">
<image
class="preview-img"
:src="captured"
mode="aspectFit"
/>
<view class="preview-actions">
<button class="preview-btn preview-btn-cancel" @click="retake">
<text>重拍</text>
</button>
<button class="preview-btn preview-btn-confirm" @click="confirm">
<text>使用</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { useCamera } from '@/composables/useCamera'
const videoId = 'signin-video'
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
useCamera(videoId)
function goBack() {
uni.navigateBack()
}
</script>
<style>
.camera-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 3;
}
/* ========== 顶部导航 ========== */
.topbar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
padding-top: calc(20rpx + env(safe-area-inset-top));
background: rgba(0, 0, 0, 0.7);
}
.back-btn {
width: 80rpx;
height: 80rpx;
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
color: #fff;
font-size: 60rpx;
font-weight: bold;
}
.topbar-title {
color: #fff;
font-size: 32rpx;
font-weight: bold;
}
.placeholder {
width: 80rpx;
}
/* ========== 视频取景区 ========== */
.viewport {
position: relative;
flex: 1;
overflow: hidden;
background: transparent;
display: flex;
justify-content: center;
align-items: center;
}
.video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.frame {
position: relative;
z-index: 1;
pointer-events: none;
}
/* A4 框: 宽度 90% 视口,SVG 内置 1:√2 比例 */
.signin-frame {
width: 90%;
height: auto;
}
/* ========== 错误层 ========== */
.error-layer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx;
background: rgba(0, 0, 0, 0.75);
border-radius: 16rpx;
max-width: 80%;
z-index: 2;
}
.error-text {
color: #fff;
font-size: 28rpx;
text-align: center;
margin-bottom: 24rpx;
line-height: 1.5;
}
.retry-btn {
padding: 12rpx 48rpx;
background: #FFD600;
color: #000;
border-radius: 32rpx;
font-size: 26rpx;
font-weight: bold;
}
/* ========== 底部控制 ========== */
.controls {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
padding: 40rpx 60rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.7);
}
.ctrl-btn {
display: flex;
flex-direction: column;
align-items: center;
background: transparent;
min-width: 120rpx;
}
.ctrl-icon {
color: #fff;
font-size: 40rpx;
line-height: 1;
margin-bottom: 8rpx;
}
.ctrl-label {
color: #ccc;
font-size: 22rpx;
}
.ctrl-btn-placeholder {
min-width: 120rpx;
}
.shutter {
width: 144rpx;
height: 144rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
border: 6rpx solid #fff;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 0 0 4rpx rgba(0, 0, 0, 0.3);
}
.shutter:active {
background: rgba(255, 255, 255, 0.4);
transform: scale(0.95);
}
.shutter-inner {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: #fff;
}
/* ========== 预览层 ========== */
.preview {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 100;
}
.preview-img {
width: 100%;
flex: 1;
}
.preview-actions {
display: flex;
flex-direction: row;
justify-content: space-around;
width: 100%;
padding: 40rpx 60rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.preview-btn {
flex: 1;
margin: 0 20rpx;
padding: 24rpx 0;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: bold;
}
.preview-btn-cancel {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.preview-btn-confirm {
background: #FFD600;
color: #000;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
{
"targets": [
"H5"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 1600" preserveAspectRatio="xMidYMid meet">
<!-- 16:9 框 — 在竖屏下显示为 9:16,宽度 80% 视口 -->
<rect x="40" y="40" width="820" height="1520" rx="20" fill="none" stroke="#00E676" stroke-width="6" stroke-dasharray="20 12"/>
<!-- 4 个角的定位标记 -->
<g stroke="#FFD600" stroke-width="6" fill="none">
<path d="M 80 80 L 80 130 M 80 80 L 130 80"/>
<path d="M 860 80 L 860 130 M 860 80 L 810 80"/>
<path d="M 80 1520 L 80 1470 M 80 1520 L 130 1520"/>
<path d="M 860 1520 L 860 1470 M 860 1520 L 810 1520"/>
</g>
<!-- 顶部标签 (正立显示,告诉用户当前画面用途) -->
<rect x="250" y="700" width="400" height="70" rx="35" fill="rgba(0,0,0,0.6)" stroke="#FFD600" stroke-width="3"/>
<text x="450" y="750" text-anchor="middle" fill="#FFD600" font-size="44" font-weight="bold">全景取景 (16:9)</text>
<!-- 中部: 当前实际显示比例提示 (正立,易读) -->
<text x="450" y="850" text-anchor="middle" fill="rgba(255,255,255,0.7)" font-size="28">当前显示 9 : 16</text>
<!-- 旋转 90° 提示 (视觉上暗示横屏拍摄效果更佳) -->
<text x="450" y="1300" text-anchor="middle" fill="rgba(255,255,255,0.85)" font-size="36" stroke="rgba(0,0,0,0.5)" stroke-width="1" transform="rotate(-90 450 1300)">建议横屏拍摄 · 横屏时为 16:9</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1414" preserveAspectRatio="xMidYMid meet">
<!-- A4 比例外框 (1 : √2,210mm × 297mm) — 宽度 90% 视口 -->
<rect x="40" y="40" width="920" height="1334" rx="20" fill="none" stroke="#00E676" stroke-width="6" stroke-dasharray="20 12"/>
<!-- 4 个角的定位标记 (帮助对齐纸张角) -->
<g stroke="#FFD600" stroke-width="6" fill="none">
<path d="M 80 80 L 80 130 M 80 80 L 130 80"/>
<path d="M 920 80 L 920 130 M 920 80 L 870 80"/>
<path d="M 80 1334 L 80 1284 M 80 1334 L 130 1334"/>
<path d="M 920 1334 L 920 1284 M 920 1334 L 870 1334"/>
</g>
<!-- 顶部标签 -->
<rect x="300" y="600" width="400" height="70" rx="35" fill="rgba(0,0,0,0.6)" stroke="#FFD600" stroke-width="3"/>
<text x="500" y="650" text-anchor="middle" fill="#FFD600" font-size="44" font-weight="bold">A4 签到表</text>
<!-- 底部提示文字 -->
<text x="500" y="1310" text-anchor="middle" fill="#fff" font-size="36" stroke="rgba(0,0,0,0.5)" stroke-width="1">将签到表完整放入绿框</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+76
View File
@@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;
+138
View File
@@ -0,0 +1,138 @@
/**
* 摄像头工具 (H5 only)
*
* - openCamera(video, facing): 申请摄像头权限并把实时流挂到 <video>
* - stopCamera(video): 停掉所有轨道
* - captureFrame(video): 截当前帧为 JPEG Base64
* - describeCameraError(err): 错误码转中文提示
*/
export type Facing = 'user' | 'environment'
export async function openCamera(video: HTMLVideoElement, facing: Facing): Promise<void> {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('当前浏览器不支持摄像头访问,请升级浏览器或使用 Chrome / Safari')
}
// 先停掉旧的 stream,防止多 stream 冲突
stopCamera(video)
const constraints: MediaStreamConstraints = {
audio: false,
video: {
facingMode: { ideal: facing },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
}
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia(constraints)
} catch (err) {
// facingMode 不被支持时降级为不指定
if ((err as DOMException)?.name === 'OverconstrainedError') {
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
})
} else {
throw err
}
}
// 三重保险赋值 srcObject:
// 1. 直接赋值 (标准做法)
// 2. 100ms 后若 readyState 仍 0,强制 Object.defineProperty
// 3. 还不行就 src=blobURL (老 API,兼容性最广)
video.srcObject = stream
console.log('[camera] 第1次赋值 srcObject, readyState:', video.readyState, 'isStream:', video.srcObject === stream)
// iOS 必须: 不加 playsinline 会弹原生全屏播放器
video.setAttribute('playsinline', 'true')
// iOS 必须: 不静音黑屏 (否则 iOS 拒绝播放)
video.muted = true
const fallbackTimer = setTimeout(() => {
if (video.readyState < 1) {
console.warn('[camera] srcObject 没生效,尝试 Object.defineProperty 强写')
try {
Object.defineProperty(video, 'srcObject', {
value: stream,
writable: true,
configurable: true,
})
console.log('[camera] defineProperty 后 srcObject isStream:', video.srcObject === stream)
} catch (e) {
console.error('[camera] defineProperty 也失败:', e)
}
// 再兜底: src=blobURL
if (video.readyState < 1) {
console.warn('[camera] 仍 readyState 0,改用 src=blobURL')
try {
video.src = URL.createObjectURL(stream)
} catch (e) {
console.error('[camera] blobURL 兜底失败:', e)
}
}
}
}, 100)
video.addEventListener(
'loadedmetadata',
() => {
clearTimeout(fallbackTimer)
console.log('[camera] loadedmetadata 触发, readyState:', video.readyState, 'isStream:', video.srcObject === stream)
},
{ once: true }
)
// 不手动调 video.play():
// 1. UTS 把 HTMLVideoElement.play() 类型当 void,链式 .catch 会报 undefined.catch
// 2. <video autoplay muted playsinline> + srcObject 已让浏览器自动起流
// 若某些浏览器不自动播放,在用户点击 shutter 等交互中再触发 play()
}
export function stopCamera(video: HTMLVideoElement): void {
const obj = video.srcObject as MediaStream | null
if (obj) {
obj.getTracks().forEach((t) => t.stop())
video.srcObject = null
}
}
export function captureFrame(video: HTMLVideoElement): string {
const w = video.videoWidth || video.clientWidth
const h = video.videoHeight || video.clientHeight
if (!w || !h) {
throw new Error('视频流尚未就绪,无法截图')
}
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法获取 canvas 2D 上下文')
ctx.drawImage(video, 0, 0, w, h)
return canvas.toDataURL('image/jpeg', 0.85)
}
export function describeCameraError(err: unknown): string {
const name = (err as DOMException)?.name || ''
switch (name) {
case 'NotAllowedError':
case 'PermissionDeniedError':
return '请在浏览器设置中允许使用摄像头权限'
case 'NotFoundError':
case 'DevicesNotFoundError':
return '未检测到摄像头设备'
case 'NotReadableError':
case 'TrackStartError':
return '摄像头被其他程序占用,请关闭后重试'
case 'OverconstrainedError':
case 'ConstraintNotSatisfiedError':
return '摄像头参数不支持,已自动降级'
case 'SecurityError':
return '请通过 HTTPS 或 localhost 访问以使用摄像头'
default:
return '打开摄像头失败:' + ((err as Error)?.message || String(err))
}
}
+38
View File
@@ -0,0 +1,38 @@
# 构建产物
target/
build/
*.class
*.jar
*.war
hs_err_pid*.log
.mvn/
# IDE
.idea/
.vscode/
*.iml
*.iws
*.ipr
.project
.classpath
.settings/
# ONNX 模型目录 — 不进 git, 由 README 引导下载/导出
# 当前默认放 v4_mobile ONNX (15MB) 在 src/main/resources/models/
# 切其他模型版本时改 application.yml 的 app.ocr.models-dir + model-version
models/
models_*/
src/main/resources/models/
src/main/resources/models_*/
src/main/resources/models_mobile/
src/main/resources/models_v5_server/
src/main/resources/models_v4_mobile/
# Python paddle 模型临时目录 (转 ONNX 用)
*.tar
paddle_*/
ch_PP-OCRv*/
# 系统
.DS_Store
Thumbs.db
+373
View File
@@ -0,0 +1,373 @@
# ry-ocr-java — 本地发票识别服务 (Spring Boot 版)
基于 **PaddleOCR ONNX Runtime + Spring Boot 3.3** 的本地部署发票识别微服务,
**完全对齐** Python 版 [ry-ocr](../ry-ocr) 的接口契约与业务逻辑。
完全离线运行,无任何云依赖,适合内网 / 等保环境。
服务默认监听 `0.0.0.0:8802`,在线文档:`http://localhost:8802/swagger-ui.html`
---
## 与 ry-ocr (Python) 的关系
| 维度 | ry-ocr (Python) | ry-ocr-java |
|---|---|---|
| 端口 | 8801 | **8802** |
| Web 框架 | FastAPI | Spring Boot 3.3 |
| OCR 引擎 | PaddleOCR 3.x (Python) | PaddleOCR ONNX Runtime (Java 推理) |
| QR 解码 | OpenCV `QRCodeDetector` | ZXing |
| PDF 渲染 | PyMuPDF (fitz) | Apache PDFBox |
| 中文金额 | cn2an (Python 库) | 自实现简化版 |
| 接口路径 | 完全一致 | 完全一致 |
| 响应字段名 | snake_case | snake_case (与 Python 一致) |
| 错误码 | 一致 | 一致 |
可与 ry-ocr **并列部署**,互不冲突;测试通过后再决定切换。
---
## 0. TL;DR
| 接口 | 用途 | 鉴权 |
|---|---|---|
| `GET /health` | 健康检查 | 无 |
| `POST /recognize/invoice` | 上传文件识别 (multipart) | 无 |
| `POST /recognize/invoice/by-path` | 服务器本地路径识别 (JSON) | 白名单 |
| `POST /recognize/text` | 纯文本字段抽取 (跳过 OCR) | 无 |
**识别流程**(默认 `app.ocr.qr-full-ocr=true`):
```
文件 → PDF/图片 → 扫 QR (ZXing) → 解出 3 字段?
├─ 是 + fast mode → 直接返回 (engine="qr", 跳过 OCR)
├─ 是 + full mode → 继续 OCR + 抽取, QR 字段覆盖 OCR 结果
└─ 否 / 格式不合法 → 直接 not_invoice, 不跑 OCR
```
---
## 1. 快速启动
### 前置条件
- JDK 17+
- Maven 3.9+
- ONNX 模型文件 (详见 [src/main/resources/models/README.md](src/main/resources/models/README.md))
### 启动
```bash
cd ry-ocr-java
mvn spring-boot:run # → http://127.0.0.1:8802
```
### Docker (TODO)
```bash
docker build -t ry-ocr-java .
docker run -p 8802:8802 ry-ocr-java
```
---
## 2. 配置项 (`application.yml`)
| 配置项 | 默认 | 说明 |
|---|---|---|
| `server.port` | `8802` | 监听端口 |
| `app.version` | `0.1.0` | 服务版本 (与 /health.version 对应) |
| `app.upload.max-mb` | `20` | 上传接口单文件最大体积 |
| `app.upload.pdf-dpi` | `150` | PDF 转图片 DPI (扫描件建议 250~300) |
| `app.ocr.page-timeout-s` | `15` | 单页 OCR 超时 |
| `app.ocr.total-timeout-s` | `60` | 整流程超时 |
| **`app.ocr.qr-full-ocr`** | **`true`** | QR 命中后是否继续跑全量 OCR |
| `app.ocr.lang` | `ch` | OCR 语言 (ch/en/chinese_cht) |
| `app.ocr.models-dir` | `models` | 模型目录 (相对/绝对) |
| `app.allowed-dirs` | `""` | `by-path` 接口允许的根目录, 空 = 禁用 |
**`qr-full-ocr` 双模式:**
| 取值 | 行为 |
|---|---|
| `true` | QR 命中 → 12 字段全抽取 (QR 3 字段覆盖 OCR) ← 默认 |
| `false` | QR 命中 → 仅返回 3 字段, 跳过 OCR (从 4.5s 降到 0.2s) |
`app.allowed-dirs` 格式:
```yaml
app:
allowed-dirs:
- "E:\\gitee\\guoju-hegui"
- "D:\\uploads"
# 或单字符串 (Windows 分号, Linux 冒号):
app:
allowed-dirs: "E:\\gitee\\guoju-hegui;D:\\uploads"
```
---
## 3. 接口详解
### 3.1 `GET /health`
健康检查。检查 OCR 引擎是否就绪。
**响应 200**
```json
{
"status": "ok",
"version": "0.1.0",
"engine_ready": true
}
```
`engine_ready=false` → 服务降级但仍能响应,建议先排查 ONNX 模型加载问题。
`/recognize/text` 接口不依赖引擎,仍可用。
---
### 3.2 `POST /recognize/invoice`
multipart/form-data 上传发票图片或 PDF。
**请求:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | file | ✅ | 图片 (PNG/JPG/JPEG/BMP/WEBP/TIFF) 或 PDF |
**curl**
```bash
curl -X POST http://localhost:8802/recognize/invoice \
-F "file=@/path/to/invoice.pdf"
```
**错误码:**
| HTTP | 场景 |
|---|---|
| 400 | 文件为空 |
| 413 | 文件超过 `max-mb` 限制 |
**响应**`InvoiceResult` 见 §4。
---
### 3.3 `POST /recognize/invoice/by-path`
传入**服务器本地路径**识别,避免重复上传大文件。
> ⚠️ **安全**:路径必须在 `app.allowed-dirs` 白名单内才会被执行。
> `app.allowed-dirs` 为空时整个接口 403(默认禁用)。
**请求体 (`PathRecognizeRequest`)**
```json
{
"file_path": "E:/invoice/abc.pdf"
}
```
> 注: JSON 字段名是 `file_path` (snake_case, 与 Python ry-ocr 完全一致).
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file_path` | string | ✅ | 服务器本地绝对路径(正反斜杠均可) |
**curl**
```bash
curl -X POST http://localhost:8802/recognize/invoice/by-path \
-H "Content-Type: application/json" \
-d '{"file_path": "E:/gitee/guoju-hegui/guoju0808/ry-ocr/fapiao.pdf"}'
```
**错误码:**
| HTTP | 场景 |
|---|---|
| 403 | 路径不在白名单, 或白名单未配置 |
| 404 | 文件不存在 |
| 400 | 不是文件 (路径是目录) |
---
### 3.4 `POST /recognize/text`
纯文本字段抽取,**不调用 OCR**。便于接入其他识别引擎(百度/腾讯/扫描件 OCR SDK 等)。
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `raw_text` | string | ✅ | OCR 原始文本 (多行用 `\n` 分隔) |
**curl**
```bash
curl -X POST 'http://localhost:8802/recognize/text?raw_text=电子发票%0A发票号码:24922000000006110014%0A价税合计(大写)叁万玖仟伍佰圆整%0A(小写)%EF%BF%A539500.00'
```
**响应:** `{"fields": {...InvoiceFields}}`
---
## 4. 响应模型
### 4.1 `InvoiceResult` (主响应)
| 字段 | 类型 | 说明 |
|---|---|---|
| `success` | bool | 整体是否成功 |
| `is_invoice` | bool | 是否被判定为发票 (false=非发票) |
| `raw_text` | string | 全部 OCR 文本拼接 (快路径为 `[QR only] ...`) |
| `lines` | OCRLine[] | 分行识别结果 |
| `fields` | InvoiceFields | 结构化字段 |
| `page_count` | int | PDF 页数 / 图片=1 |
| `engine` | string | `paddleocr` / `qr` |
| `elapsed_ms` | int | 服务端识别耗时 (毫秒) |
| `error` | string? | 失败原因描述 |
| `error_code` | string? | 见 §4.4 错误码表 |
| `from_qr` | bool | 是否从 QR 取到了 3 个核心字段 |
| `qr_raw` | string? | 二维码原始文本 (排查用) |
| `qr_error` | string? | `no_qr` / `bad_format` |
### 4.2 `InvoiceFields` (fields 子对象)
| 字段 | 类型 | 来源 |
|---|---|---|
| `invoice_type` | string? | OCR: "电子发票"/"增值税专用发票"等 |
| `invoice_no` | string? | **QR (权威)** / OCR |
| `invoice_code` | string? | OCR (数电票此字段为空) |
| `invoice_date` | string (YYYY-MM-DD) | **QR (权威)** / OCR |
| `amount` | float? | **QR (权威)** / OCR — 价税合计小写 |
| `amount_cn` | string? | OCR — 价税合计大写 |
| `amount_pretax` | float? | OCR — 不含税金额 |
| `tax_amount` | float? | OCR — 税额 |
| `seller_name` | string? | OCR |
| `seller_tax_no` | string? | OCR |
| `buyer_name` | string? | OCR |
| `buyer_tax_no` | string? | OCR |
| `amount_match` | bool? | 大写金额 vs 小写金额一致性 |
### 4.3 `OCRLine`
```json
{
"text": "发票号码:24922000000006110014",
"confidence": 0.998,
"box": [[915, 67], [1191, 67], [1191, 83], [915, 83]]
}
```
### 4.4 `error_code` 表
| 取值 | 含义 | 触发场景 |
|---|---|---|
| `not_invoice` | 非发票 | QR 没扫到 / 格式不合法 |
| `unsupported` | 不支持的文件类型 | 后缀不是 PDF/图片 |
| `process_failed` | 处理失败 | PDF 渲染异常等 |
| `timeout` | 超时 | 达到单页/总流程超时 |
| `ocr_failed` | OCR 异常 | ONNX 推理内部错误 |
---
## 5. 错误码速查
调用方拿到响应后建议这样分流:
```java
if (!resp.isSuccess()) {
if ("not_invoice".equals(resp.getErrorCode())) {
// 不是发票 — 直接告诉用户"请上传发票图片"
} else if ("timeout".equals(resp.getErrorCode())) {
// 超时 — 建议重试 / 提高 DPI
} else if ("unsupported".equals(resp.getErrorCode())
|| "process_failed".equals(resp.getErrorCode())) {
// 文件问题 — 提示格式
} else {
// 其他 OCR 异常 — 兜底
}
}
if (Boolean.FALSE.equals(resp.getIsInvoice())) {
// 跟 not_invoice 等价 — 多数情况下 success=False 也伴随 is_invoice=False
}
```
---
## 6. Java 客户端调用 (hutool)
```java
@Service
public class InvoiceOcrService {
private final OcrClient ocrClient = new OcrClient("http://127.0.0.1:8802");
public InvoiceResult recognize(MultipartFile file) {
File tmp;
try {
tmp = File.createTempFile("inv_", "_" + file.getOriginalFilename());
file.transferTo(tmp);
} catch (IOException e) {
throw new RuntimeException("保存临时文件失败", e);
}
try {
InvoiceResult r = ocrClient.recognize(tmp);
if (!Boolean.TRUE.equals(r.getSuccess())) {
throw new RuntimeException("OCR 识别失败: " + r.getError());
}
return r;
} finally {
tmp.delete();
}
}
}
```
> 客户端类 (`OcrClient` / `InvoiceResult` / `InvoiceFields` / `OcrLine`) 直接复用
> `ry-ocr/client/*.java`,只需改 baseUrl 为 `http://127.0.0.1:8802`。
>
> 注意: 由于 Java 客户端使用 camelCase (`getInvoiceNo`) 解析 JSON,
> 而服务端返回 snake_case (`invoice_no`), 现有客户端需要适配字段名。
> 详见 `ry-ocr-java` 与 `ry-api` 的字段映射对照。
---
## 7. 项目结构
```
ry-ocr-java/
├── pom.xml
└── src/main/
├── java/com/ruoyi/ocr/
│ ├── OcrApplication.java # Spring Boot 入口
│ ├── config/OcrProperties.java # @ConfigurationProperties("app")
│ ├── api/OcrController.java # 4 个 REST 接口
│ ├── core/
│ │ ├── OcrEngine.java # ONNX Runtime + 单例 + 超时
│ │ ├── TextDetector.java # DB 检测
│ │ ├── TextRecognizer.java # CRNN 识别
│ │ ├── DbPostProcessor.java # DB 后处理 (box 提取)
│ │ ├── CtcDecoder.java # CTC 解码
│ │ ├── Dictionary.java # 字典加载
│ │ ├── PdfProcessor.java # PDFBox 渲染
│ │ └── ImageProcessor.java # Java 2D 旋转/增强
│ ├── model/ # 5 个 DTO + QrDecodeResult
│ ├── service/
│ │ ├── QrDecoder.java # ZXing + 8 字段解析
│ │ ├── InvoiceExtractor.java # 正则 + box 坐标归属
│ │ └── RecognizeService.java # 端到端流水线
│ ├── util/AmountUtils.java # cn2an 简化版 + 金额正则
│ └── exception/OcrTimeoutException.java
└── resources/
├── application.yml
└── models/ # ONNX 模型 (需手动放)
```
---
## 8. 局限 & 后续
- **无 QR 的老式纸质发票** 当前会判 `not_invoice` — 需新增「无 QR 回退 OCR」配置项
- **表格明细** (货物/数量/单价) 未抽取 — 需要时接 PP-Structure
- **字段抽取基于正则**,对版式变化敏感
- **DB 后处理简化**Java 版用 bounding box + unclip 替代 PaddleOCR 原始的
polygon + findContours;精度可能略低,可后续替换
- **并发**ONNX Runtime 内部串行推理;HTTP 层 Tomcat 默认 200 线程
- **客户端字段映射**:现有 Java `OcrClient` 用 camelCase 解析 JSON,需适配
snake_case 字段名(建议字段名都改成 @JsonProperty 显式标注)
+148
View File
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ruoyi</groupId>
<artifactId>ry-ocr-java</artifactId>
<version>0.1.0</version>
<name>ry-ocr-java</name>
<description>本地发票识别服务 (PaddleOCR ONNX Runtime + Spring Boot) — 1:1 移植自 ry-ocr Python</description>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>3.3.5</spring-boot.version>
<onnxruntime.version>1.20.0</onnxruntime.version>
<zxing.version>3.5.3</zxing.version>
<pdfbox.version>2.0.31</pdfbox.version>
<hutool.version>5.8.27</hutool.version>
<lombok.version>1.18.30</lombok.version>
<springdoc.version>2.6.0</springdoc.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- ONNX Runtime (PaddleOCR 推理) -->
<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>${onnxruntime.version}</version>
</dependency>
<!-- ZXing QR 二维码解码 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>${zxing.version}</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>${zxing.version}</version>
</dependency>
<!-- PDFBox PDF 渲染 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<!-- Hutool (JSON/IO/Date 工具) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- springdoc-openapi (Swagger UI /docs) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>ry-ocr-java</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.ruoyi.ocr.OcrApplication</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases>
<enabled>true</enabled>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>
@@ -0,0 +1,19 @@
package com.ruoyi.ocr;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
/**
* ry-ocr-java 入口
* <p>
* 默认端口 8802 (与 ry-ocr Python 8801 并列部署, 不冲突).
*/
@SpringBootApplication
@ConfigurationPropertiesScan("com.ruoyi.ocr.config")
public class OcrApplication {
public static void main(String[] args) {
SpringApplication.run(OcrApplication.class, args);
}
}
@@ -0,0 +1,85 @@
package com.ruoyi.ocr.api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 全局异常处理 — 把 Spring 内部异常翻译为 ry-ocr 一致的 HTTP 码
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 上传文件超过 Spring max-file-size → 413
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<?> handleUploadTooLarge(MaxUploadSizeExceededException e) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Map.of("detail", "文件超过大小限制"));
}
/**
* @Valid 校验失败 → 400
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("detail", msg.isEmpty() ? "参数校验失败" : msg));
}
/**
* JSON 解析失败 → 400
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<?> handleBadJson(HttpMessageNotReadableException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("detail", "请求体格式错误: " + (e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage())));
}
/**
* multipart 缺 file 字段 → 400
*/
@ExceptionHandler(MissingServletRequestPartException.class)
public ResponseEntity<?> handleMissingPart(MissingServletRequestPartException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("detail", "缺少请求字段: " + e.getRequestPartName()));
}
/**
* OCR 引擎未就绪 → 503
*/
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<?> handleEngineNotReady(IllegalStateException e) {
if (e.getMessage() != null && e.getMessage().contains("OCR 引擎未就绪")) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("detail", e.getMessage()));
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("detail", "内部错误: " + e.getMessage()));
}
/**
* 兜底
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleAny(Exception e) {
log.error("未处理异常", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("detail", "内部错误: " + e.getClass().getSimpleName() + " - " + e.getMessage()));
}
}
@@ -0,0 +1,181 @@
package com.ruoyi.ocr.api;
import com.ruoyi.ocr.config.OcrProperties;
import com.ruoyi.ocr.core.OcrEngine;
import com.ruoyi.ocr.model.HealthResponse;
import com.ruoyi.ocr.model.InvoiceFields;
import com.ruoyi.ocr.model.InvoiceResult;
import com.ruoyi.ocr.model.PathRecognizeRequest;
import com.ruoyi.ocr.service.InvoiceExtractor;
import com.ruoyi.ocr.service.RecognizeService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* OCR REST 控制器 — 对齐 Python app.api.routes
* <p>
* 4 个接口:
* - GET /health
* - POST /recognize/invoice (multipart file)
* - POST /recognize/invoice/by-path (JSON {file_path}, 路径白名单)
* - POST /recognize/text (Query: raw_text, 仅字段抽取)
*/
@Slf4j
@RestController
@RequiredArgsConstructor
public class OcrController {
private final OcrProperties props;
private final OcrEngine ocrEngine;
private final RecognizeService recognizeService;
private final InvoiceExtractor extractor;
// ---------- /health ----------
@GetMapping("/health")
public HealthResponse health() {
return new HealthResponse(
ocrEngine.isReady() ? "ok" : "degraded",
props.getVersion(),
ocrEngine.isReady()
);
}
// ---------- /recognize/invoice (multipart) ----------
@PostMapping(value = "/recognize/invoice", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<?> recognizeInvoice(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("detail", "文件为空"));
}
long maxBytes = (long) props.getUpload().getMaxMb() * 1024 * 1024;
if (file.getSize() > maxBytes) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Map.of("detail", "文件超过 " + props.getUpload().getMaxMb() + "MB 限制"));
}
try {
byte[] content = file.getBytes();
InvoiceResult r = recognizeService.recognizeFile(file.getOriginalFilename(), content);
return ResponseEntity.ok(r);
} catch (Exception e) {
log.error("recognize 失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("detail", "识别失败: " + e.getMessage()));
}
}
// ---------- /recognize/invoice/by-path (JSON, 白名单) ----------
@PostMapping(value = "/recognize/invoice/by-path",
consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> recognizeByPath(@Valid @RequestBody PathRecognizeRequest req) {
Path p;
try {
p = Paths.get(req.getFilePath());
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("detail", "路径无效: " + e.getMessage()));
}
// 白名单校验
ResponseEntity<?> guard = checkPathAllowed(p);
if (guard != null) return guard;
if (!Files.exists(p)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("detail", "文件不存在: " + p));
}
if (!Files.isRegularFile(p)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("detail", "不是文件: " + p));
}
InvoiceResult r = recognizeService.recognizePath(p, false);
return ResponseEntity.ok(r);
}
// ---------- /recognize/text (Query raw_text, 不调 OCR) ----------
@PostMapping("/recognize/text")
public Map<String, Object> recognizeText(@RequestParam("raw_text") String rawText) {
// /recognize/text 没有 QR, 传 null 让 extractor 走 OCR TOTAL_PATTERN 兜底
InvoiceFields fields = extractor.extract(rawText, null, null);
return Map.of("fields", fields);
}
// ---------- 白名单工具 ----------
private ResponseEntity<?> checkPathAllowed(Path filePath) {
List<Path> roots = parseAllowedDirs();
if (roots.isEmpty()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"detail", "路径接口未启用: 在 application.yml 配置 app.allowed-dirs 后重启服务"
));
}
Path absPath;
try {
absPath = filePath.toAbsolutePath().normalize();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("detail", "路径无效: " + e.getMessage()));
}
for (Path root : roots) {
if (absPath.startsWith(root)) {
return null;
}
}
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"detail", "路径不在白名单内(允许: " + roots + ""
));
}
/**
* 解析 app.allowed-dirs 配置: List 或 String(";" / ":" 分隔, 跨平台)
*/
private List<Path> parseAllowedDirs() {
List<String> raw = props.getAllowedDirs();
if (raw == null || raw.isEmpty()) return List.of();
// 兼容: 单元素可能是 ";D:\\a;E:\\b" 形式
List<String> expanded = new ArrayList<>();
for (String s : raw) {
if (s == null) continue;
String sep = s.contains(";") ? ";" : (s.contains(":") && !s.matches("^[A-Z]:.*") ? ":" : null);
if (sep == null) {
expanded.add(s.trim());
} else {
for (String part : s.split(java.util.regex.Pattern.quote(sep))) {
String t = part.trim();
if (!t.isEmpty()) expanded.add(t);
}
}
}
List<Path> roots = new ArrayList<>();
for (String r : expanded) {
if (r.isEmpty()) continue;
try {
Path p = Paths.get(r).toAbsolutePath().normalize();
if (Files.isDirectory(p)) {
roots.add(p);
}
} catch (Exception e) {
log.warn("allowed-dirs 解析失败: {} ({})", r, e.getMessage());
}
}
return roots;
}
}
@@ -0,0 +1,71 @@
package com.ruoyi.ocr.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* ry-ocr-java 配置 (对应 Python app/config.py + .env)
* <p>
* 绑定 application.yml 中 app.* 配置段.
*/
@Data
@ConfigurationProperties(prefix = "app")
public class OcrProperties {
/** 服务版本号 (对应 Python __version__) */
private String version = "0.1.0";
private final Upload upload = new Upload();
private final Ocr ocr = new Ocr();
/**
* 路径白名单: 空字符串/空列表 = 禁用 by-path 接口
* <p>
* 支持: yml 数组 ["E:\\a", "D:\\b"] 或单字符串 "E:\\a;D:\\b" (Windows 分号分隔)
*/
private List<String> allowedDirs = new ArrayList<>();
@Data
public static class Upload {
/** 单文件最大 MB, 超限返回 HTTP 413 */
private int maxMb = 20;
/** PDF 转图片 DPI */
private int pdfDpi = 150;
}
@Data
public static class Ocr {
/** 单页 OCR 超时 (秒) */
private int pageTimeoutS = 15;
/** 整流程 OCR 超时 (秒) */
private int totalTimeoutS = 60;
/** QR 命中后是否继续跑全量 OCR + 字段抽取 (false = 快路径, 只返回 QR 3 字段) */
private boolean qrFullOcr = true;
/** OCR 语言: ch (简中) / en / chinese_cht */
private String lang = "ch";
/** 模型目录: 相对路径 → classpath:models/, 绝对路径 → 直读 */
private String modelsDir = "models";
/** PDF 文件优先抽内嵌文本层 (pdftotext 等价) — 抽到非空文本则跳过 ONNX.
* 适用电子发票 / 数电票 PDF (含真实文本); 扫描件 PDF 会回退到 ONNX. */
private boolean usePdfTextFirst = true;
/** 内嵌文本字符数低于此值视为无效, 回退到 ONNX */
private int pdfTextMinChars = 30;
/** 模型版本 (决定 CRNN 输入高度):
* <ul>
* <li>v5_server — H=48, maxW=320 (PaddleOCR v5 server 多语言模型, 90MB+ 大, 精度最高)</li>
* <li>v5_mobile — H=48, maxW=320 (PaddleOCR v5 mobile 多语言模型, 20MB, 推荐折中)</li>
* <li>v4_mobile — H=32, maxW=320 (PaddleOCR v4 mobile 中文模型, 15MB, 最快, 中文精度略低)</li>
* </ul>
* 切换时改 models-dir + 本字段 + 重启即可. */
private String modelVersion = "v5_server";
/** CRNN 输入高度 (覆盖 modelVersion 默认值). 高级用户用, 一般不动. */
private Integer recHeight = null;
/** CRNN 最大宽度 (覆盖 modelVersion 默认值) */
private Integer recMaxWidth = null;
/** DB 检测最长边限制 (覆盖 modelVersion 默认值) */
private Integer detMaxSide = null;
}
}
@@ -0,0 +1,53 @@
package com.ruoyi.ocr.core;
/**
* CTC greedy decoder: argmax → 去连续重复 → 去 blank (idx 0) → 字典查表.
*/
public final class CtcDecoder {
private CtcDecoder() {}
/**
* @param logits [T, N]
* @param dict 字典 (idx 0 = blank)
* @return RecognizedText
*/
public static TextRecognizer.RecognizedText decode(float[][] logits, Dictionary dict) {
int t = logits.length;
if (t == 0) return new TextRecognizer.RecognizedText("", 0.0);
StringBuilder sb = new StringBuilder();
int lastIdx = -1;
double confSum = 0;
int confCount = 0;
for (int i = 0; i < t; i++) {
int bestIdx = 0;
float bestVal = Float.NEGATIVE_INFINITY;
for (int j = 0; j < logits[i].length; j++) {
if (logits[i][j] > bestVal) {
bestVal = logits[i][j];
bestIdx = j;
}
}
if (bestIdx != 0 && bestIdx != lastIdx) {
// 跳过 blank (0), 跳过与上一次相同的 (CTC 合并)
if (bestIdx < dict.size()) {
sb.append(dict.getCharacters().get(bestIdx));
}
// softmax → exp / sum
double sumExp = 0;
for (int j = 0; j < logits[i].length; j++) {
sumExp += Math.exp(logits[i][j] - bestVal);
}
double prob = 1.0 / sumExp;
confSum += prob;
confCount++;
}
lastIdx = bestIdx;
}
double conf = confCount == 0 ? 0 : confSum / confCount;
return new TextRecognizer.RecognizedText(sb.toString(), conf);
}
}
@@ -0,0 +1,150 @@
package com.ruoyi.ocr.core;
import java.awt.geom.Path2D;
import java.awt.geom.PathIterator;
import java.util.ArrayList;
import java.util.List;
/**
* DB (Differentiable Binarization) 后处理 — 从概率图提取文本框 polygon.
* <p>
* 复刻 Python PaddleOCR 的 db_post_process / boxes_from_bitmap.
*/
public final class DbPostProcessor {
private DbPostProcessor() {}
/**
* @param prob [H, W] 概率图
* @param resizeH/W 概率图对应的输入图尺寸
* @param origH/W 原图尺寸 (用于映射回原图坐标)
*/
public static List<List<Float>> postProcess(float[][] prob, int resizeH, int resizeW,
int origH, int origW,
float dbThresh, float boxThresh,
float unclipRatio) {
// 1. 二值化 + 膨胀 (这里简化为阈值 + 内置 unclipRatio 计算 box)
// 生产实现需要 findContours, 这里用简化: 标记连通分量 + bounding box + unclip
boolean[][] mask = new boolean[resizeH][resizeW];
for (int y = 0; y < resizeH; y++) {
for (int x = 0; x < resizeW; x++) {
mask[y][x] = prob[y][x] >= dbThresh;
}
}
// 简易膨胀 (3x3)
boolean[][] dilated = dilate(mask, resizeH, resizeW);
// 简易 8 邻接连通分量
List<int[][]> components = connectedComponents(dilated, resizeH, resizeW);
// 过滤 + unclip
float scaleX = (float) origW / resizeW;
float scaleY = (float) origH / resizeH;
List<List<Float>> boxes = new ArrayList<>();
for (int[][] component : components) {
int minX = component[0][0], minY = component[0][1];
int maxX = component[0][0], maxY = component[0][1];
int area = 0;
for (int[] p : component) {
if (p[0] < minX) minX = p[0];
if (p[0] > maxX) maxX = p[0];
if (p[1] < minY) minY = p[1];
if (p[1] > maxY) maxY = p[1];
area++;
}
// box_thresh 过滤: 用平均 prob 二次过滤
if (area < 3) continue;
float meanProb = 0;
for (int[] p : component) {
meanProb += prob[p[1]][p[0]];
}
meanProb /= area;
if (meanProb < boxThresh) continue;
// unclip: 扩展 box (简化为固定比例放大)
int w = maxX - minX + 1, h = maxY - minY + 1;
int dx = (int) (w * (unclipRatio - 1) / 2);
int dy = (int) (h * (unclipRatio - 1) / 2);
minX = Math.max(0, minX - dx);
minY = Math.max(0, minY - dy);
maxX = Math.min(resizeW - 1, maxX + dx);
maxY = Math.min(resizeH - 1, maxY + dy);
List<Float> box = new ArrayList<>();
float[][] corners = {
{minX * scaleX, minY * scaleY},
{maxX * scaleX, minY * scaleY},
{maxX * scaleX, maxY * scaleY},
{minX * scaleX, maxY * scaleY}
};
for (float[] c : corners) {
box.add(c[0]);
box.add(c[1]);
}
boxes.add(box);
}
// 按 y 排序 (从上到下)
boxes.sort((a, b) -> Float.compare(a.get(1), b.get(1)));
return boxes;
}
private static boolean[][] dilate(boolean[][] src, int h, int w) {
boolean[][] dst = new boolean[h][w];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
boolean any = false;
for (int dy = -1; dy <= 1 && !any; dy++) {
for (int dx = -1; dx <= 1 && !any; dx++) {
int ny = y + dy, nx = x + dx;
if (ny >= 0 && ny < h && nx >= 0 && nx < w && src[ny][nx]) {
any = true;
}
}
}
dst[y][x] = any;
}
}
return dst;
}
private static List<int[][]> connectedComponents(boolean[][] mask, int h, int w) {
boolean[][] visited = new boolean[h][w];
List<int[][]> result = new ArrayList<>();
int[] dx = {-1, 0, 1, -1, 1, -1, 0, 1};
int[] dy = {-1, -1, -1, 0, 0, 1, 1, 1};
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
if (!mask[y][x] || visited[y][x]) continue;
List<int[]> comp = new ArrayList<>();
java.util.Deque<int[]> stack = new java.util.ArrayDeque<>();
stack.push(new int[]{x, y});
visited[y][x] = true;
while (!stack.isEmpty()) {
int[] p = stack.pop();
comp.add(p);
for (int i = 0; i < 8; i++) {
int nx = p[0] + dx[i], ny = p[1] + dy[i];
if (nx >= 0 && nx < w && ny >= 0 && ny < h && mask[ny][nx] && !visited[ny][nx]) {
visited[ny][nx] = true;
stack.push(new int[]{nx, ny});
}
}
}
result.add(comp.toArray(new int[0][]));
}
}
return result;
}
// 工具: polygon → bounding box
public static int[] bbox(List<Float> box) {
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;
float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
for (int i = 0; i < box.size(); i += 2) {
float x = box.get(i), y = box.get(i + 1);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
return new int[]{Math.round(minX), Math.round(minY), Math.round(maxX), Math.round(maxY)};
}
}
@@ -0,0 +1,56 @@
package com.ruoyi.ocr.core;
import lombok.Getter;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* 中文字典加载 — 加载 PaddleOCR ppocr_keys_v1.txt
* <p>
* 模型输出约定: PP-OCRv5 multilingual 的输出维度 = len(dict_file_lines) + 2, 其中
* <ul>
* <li>idx 0 = CTC blank (不查表, 由解码器跳过)</li>
* <li>idx 1 = " " (半角空格, 多语言模型中英文混排用, 文件中无此行)</li>
* <li>idx 2..N+1 = 字典字符 (来自 ppocr_keys_v1.txt 的每一行)</li>
* </ul>
* 因此本类在加载时**主动在 idx 0 插入空串占位, idx 1 插入半角空格**, 使
* dict.characters[2] 对应文件第 1 行.
* <p>
* ⚠️ 此约定根据 monkt/paddleocr-onnx PP-OCRv5 mobile 输出维度 18385 (文件 18383 行 + 2)
* 反推得出, 适配大多数 PP-OCRv5 导出模型.
*/
@Getter
public class Dictionary {
/** 字符表: idx 0 = CTC blank 占位, idx 1 = " " (半角空格), idx 2.. = 文件字符 */
private final List<String> characters;
private Dictionary(List<String> characters) {
this.characters = characters;
}
public int size() {
return characters.size();
}
public static Dictionary load(Path dictPath) throws IOException {
List<String> chars = new ArrayList<>();
// idx 0 = CTC blank 占位 (模型 output[0] = blank, 由 CtcDecoder 跳过)
chars.add("");
try (BufferedReader r = Files.newBufferedReader(dictPath, StandardCharsets.UTF_8)) {
String line;
while ((line = r.readLine()) != null) {
if (!line.isEmpty()) {
chars.add(line);
}
}
}
return new Dictionary(chars);
}
}
@@ -0,0 +1,227 @@
package com.ruoyi.ocr.core;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
/**
* 图像预处理: 自动旋转 / 轻度增强 — 对齐 Python app.core.image_processor
* <p>
* 全部使用 Java 2D, 无需引入 OpenCV.
*/
@Slf4j
public final class ImageProcessor {
private ImageProcessor() {}
/**
* 简易方向校正: 纵向图 (高 > 宽 × 1.2) 顺时针旋转 90°.
* <p>
* 复杂倾斜交给 ONNX 引擎自带的 textline orientation.
*/
public static Path autoRotate(Path imgPath) {
Path p = imgPath;
BufferedImage img;
try {
img = ImageIO.read(p.toFile());
} catch (IOException e) {
log.warn("read image failed: {}", e.getMessage());
return p;
}
if (img == null) {
return p;
}
int h = img.getHeight();
int w = img.getWidth();
if (h > w * 1.2) {
BufferedImage rotated = rotate90Clockwise(img);
Path out = p.resolveSibling(stem(p) + "_rot.png");
try {
ImageIO.write(rotated, "png", out.toFile());
return out;
} catch (IOException e) {
log.warn("write rotated image failed: {}", e.getMessage());
}
}
return p;
}
/**
* 轻度增强: 灰度化 + (低对比度图) 自适应二值化.
*/
public static Path enhance(Path imgPath) {
Path p = imgPath;
BufferedImage img;
try {
img = ImageIO.read(p.toFile());
} catch (IOException e) {
log.warn("read image failed: {}", e.getMessage());
return p;
}
if (img == null) {
return p;
}
BufferedImage gray = toGray(img);
double std = stddev(gray);
if (std < 50) {
BufferedImage binary = adaptiveThreshold(gray, 31, 10);
Path out = p.resolveSibling(stem(p) + "_enh.png");
try {
ImageIO.write(binary, "png", out.toFile());
return out;
} catch (IOException e) {
log.warn("write enhanced image failed: {}", e.getMessage());
}
}
return p;
}
// ---------- 内部 ----------
private static String stem(Path p) {
String name = p.getFileName().toString();
int dot = name.lastIndexOf('.');
return dot > 0 ? name.substring(0, dot) : name;
}
private static BufferedImage rotate90Clockwise(BufferedImage src) {
int w = src.getWidth();
int h = src.getHeight();
BufferedImage dst = new BufferedImage(h, w, src.getType() == 0 ? BufferedImage.TYPE_INT_RGB : src.getType());
Graphics2D g = dst.createGraphics();
AffineTransform tx = new AffineTransform();
tx.translate(h, 0);
tx.rotate(Math.toRadians(90));
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(src, tx, null);
g.dispose();
return dst;
}
private static BufferedImage toGray(BufferedImage src) {
if (src.getType() == BufferedImage.TYPE_BYTE_GRAY) return src;
BufferedImage gray = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = gray.createGraphics();
g.drawImage(src, 0, 0, null);
g.dispose();
return gray;
}
/**
* 计算灰度图标准差 (判断是否低对比度)
* <p>
* 优化: 用 getRGB() 一次性读出整张图到 int[], 直接遍历 byte 提取亮度, 避免 Raster.getSample 逐元素调用 (慢 10x+).
*/
private static double stddev(BufferedImage gray) {
int w = gray.getWidth();
int h = gray.getHeight();
int[] pixels = new int[w * h];
gray.getRGB(0, 0, w, h, pixels, 0, w);
long sum = 0;
long sumSq = 0;
long count = 0;
// 采样: 每 4 像素采样一次 (避免遍历几百万像素)
for (int i = 0; i < pixels.length; i += 16) {
int v = pixels[i] >>> 24 == 0 ? pixels[i] & 0xFF : (pixels[i] >> 16) & 0xFF; // 灰度图 R=G=B
// TYPE_BYTE_GRAY 的灰度值在 R/G/B 都一样, 取 R 即可
// 直接按 TYPE_BYTE_GRAY: ARGB 编码时 R 通道存的就是灰度
sum += v;
sumSq += (long) v * v;
count++;
}
if (count == 0) return 0;
double mean = (double) sum / count;
double variance = ((double) sumSq / count) - mean * mean;
return Math.sqrt(Math.max(0, variance));
}
/**
* 简易自适应二值化 (高斯加权 + 常数偏移).
* <p>
* 与 cv2.adaptiveThreshold(..., ADAPTIVE_THRESH_GAUSSIAN_C, ...) 行为近似.
* <p>
* 优化: 整图 getRGB 一次性读出, 用 byte[] 操作, 避免 Raster 逐元素访问.
*/
private static BufferedImage adaptiveThreshold(BufferedImage gray, int blockSize, int C) {
int w = gray.getWidth();
int h = gray.getHeight();
int[] srcPixels = new int[w * h];
gray.getRGB(0, 0, w, h, srcPixels, 0, w);
// 提取灰度字节 (TYPE_BYTE_GRAY 在 BufferedImage 内部实际存为 TYPE_INT_ARGB 但 R 通道就是灰度)
byte[] srcGray = new byte[w * h];
for (int i = 0; i < srcPixels.length; i++) {
srcGray[i] = (byte) (srcPixels[i] & 0xFF);
}
// 先做 box blur (近似高斯)
byte[] blurredGray = boxBlurBytes(srcGray, w, h, blockSize);
BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_BINARY);
byte[] outData = new byte[w * h];
for (int y = 0; y < h; y++) {
int rowStart = y * w;
for (int x = 0; x < w; x++) {
int idx = rowStart + x;
int src = srcGray[idx] & 0xFF;
int bg = blurredGray[idx] & 0xFF;
int v = src - bg + 127 - C;
outData[idx] = (byte) (v > 127 ? 255 : 0);
}
}
out.getRaster().setDataElements(0, 0, w, h, outData);
return out;
}
/**
* 轻量 box blur (半径 = blockSize / 2) — 纯 byte[] 数组操作, 无 Raster 开销
*/
private static byte[] boxBlurBytes(byte[] gray, int w, int h, int blockSize) {
int radius = Math.max(1, blockSize / 2);
// 构造积分图 (像素值累加, 0-255)
int[] integral = new int[w * h];
for (int y = 0; y < h; y++) {
int rowSum = 0;
int rowStart = y * w;
for (int x = 0; x < w; x++) {
rowSum += gray[rowStart + x] & 0xFF;
integral[rowStart + x] = rowSum + (y > 0 ? integral[rowStart + x - w] : 0);
}
}
byte[] out = new byte[w * h];
for (int y = 0; y < h; y++) {
int y1 = Math.max(0, y - radius);
int y2 = Math.min(h - 1, y + radius);
int rowStart = y * w;
for (int x = 0; x < w; x++) {
int x1 = Math.max(0, x - radius);
int x2 = Math.min(w - 1, x + radius);
int area = (x2 - x1 + 1) * (y2 - y1 + 1);
int sum = integral[y2 * w + x2];
if (x1 > 0) sum -= integral[y2 * w + (x1 - 1)];
if (y1 > 0) sum -= integral[(y1 - 1) * w + x2];
if (x1 > 0 && y1 > 0) sum += integral[(y1 - 1) * w + (x1 - 1)];
out[rowStart + x] = (byte) (sum / area);
}
}
return out;
}
/** 加载 BufferedImage (封装异常) */
public static BufferedImage read(Path p) throws IOException {
return ImageIO.read(p.toFile());
}
/** 保存 BufferedImage (封装异常) */
public static void write(BufferedImage img, Path p) throws IOException {
File f = p.toFile();
if (f.getParentFile() != null) {
f.getParentFile().mkdirs();
}
ImageIO.write(img, "png", f);
}
}
@@ -0,0 +1,218 @@
package com.ruoyi.ocr.core;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtException;
import com.ruoyi.ocr.config.OcrProperties;
import com.ruoyi.ocr.exception.OcrTimeoutException;
import com.ruoyi.ocr.model.OcrLine;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.*;
/**
* OCR 引擎单例 — 对齐 Python app.core.ocr_engine
* <p>
* 单例 + 单页超时 (Future.get(timeout)).
* 底层: PaddleOCR ONNX Runtime (det + rec).
*/
@Slf4j
@Component
public class OcrEngine {
private final OcrProperties props;
private final ExecutorService executor = Executors.newFixedThreadPool(2, r -> {
Thread t = new Thread(r, "ocr-worker");
t.setDaemon(true);
return t;
});
private OrtEnvironment ortEnv;
private TextDetector detector;
private TextRecognizer recognizer;
private volatile boolean ready = false;
public OcrEngine(OcrProperties props) {
this.props = props;
}
/**
* 启动预热: 加载 ONNX Session
*/
@PostConstruct
public synchronized void warmup() {
try {
Path modelsDir = resolveModelsDir();
Path detPath = modelsDir.resolve("det.onnx");
Path recPath = modelsDir.resolve("rec.onnx");
Path dictPath = modelsDir.resolve("ppocr_keys_v1.txt");
if (!Files.exists(detPath) || !Files.exists(recPath) || !Files.exists(dictPath)) {
log.warn("OCR 模型未找到 ({}/det.onnx + rec.onnx + ppocr_keys_v1.txt), 引擎未就绪, /health 返回 degraded", modelsDir);
return;
}
log.info("加载 OCR 模型 from {} (version={})", modelsDir, props.getOcr().getModelVersion());
long t0 = System.currentTimeMillis();
this.ortEnv = OrtEnvironment.getEnvironment();
this.detector = new TextDetector(ortEnv, detPath, resolveDetMaxSide());
Dictionary dict = Dictionary.load(dictPath);
this.recognizer = new TextRecognizer(ortEnv, recPath, dict, resolveRecHeight(), resolveRecMaxW());
this.ready = true;
log.info("OCR 引擎就绪, 耗时 {}ms (recHeight={}, recMaxW={}, detMaxSide={})",
System.currentTimeMillis() - t0, resolveRecHeight(), resolveRecMaxW(), resolveDetMaxSide());
} catch (Exception e) {
log.warn("OCR 引擎初始化失败: {}", e.getMessage(), e);
}
}
/**
* 解析模型目录: 相对路径 → classpath:models/, 绝对路径 → 直读
*/
private Path resolveModelsDir() {
String dir = props.getOcr().getModelsDir();
Path p = Path.of(dir);
if (p.isAbsolute()) return p;
// classpath: resources/models/
try {
java.net.URL url = getClass().getClassLoader().getResource(dir);
if (url != null && "file".equals(url.getProtocol())) {
return Path.of(url.toURI());
}
} catch (Exception ignored) {}
// 回退: ./target/classes/models/
return Path.of("src/main/resources", dir);
}
/** 解析 CRNN 输入高度: 显式 rec-height > modelVersion 默认 */
private int resolveRecHeight() {
Integer override = props.getOcr().getRecHeight();
if (override != null) return override;
return switch (props.getOcr().getModelVersion()) {
case "v5_server", "v5_mobile" -> 48;
case "v4_mobile" -> 32;
default -> 48;
};
}
private int resolveRecMaxW() {
Integer override = props.getOcr().getRecMaxWidth();
if (override != null) return override;
return 320;
}
private int resolveDetMaxSide() {
Integer override = props.getOcr().getDetMaxSide();
if (override != null) return override;
return switch (props.getOcr().getModelVersion()) {
case "v5_server", "v5_mobile" -> 800; // 优化: v5 原生 960 → 800, 算力 -31%
case "v4_mobile" -> 960;
default -> 960;
};
}
@PreDestroy
public void close() {
try {
if (recognizer != null) recognizer.close();
if (detector != null) detector.close();
} catch (Exception e) {
log.warn("close engine error: {}", e.getMessage());
}
executor.shutdownNow();
}
public boolean isReady() {
return ready;
}
/**
* 识别单张图片 — 带单页超时
*/
public List<OcrLine> recognize(Path imagePath) {
if (!ready) {
throw new IllegalStateException("OCR 引擎未就绪, 请检查 models/ 目录下是否有 det.onnx / rec.onnx / ppocr_keys_v1.txt");
}
int timeoutSec = props.getOcr().getPageTimeoutS();
Future<List<OcrLine>> future = executor.submit(() -> doRecognize(imagePath));
try {
return future.get(timeoutSec, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new OcrTimeoutException("OCR 识别超时 (" + timeoutSec + "秒): " + imagePath);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new OcrRuntimeException("OCR 中断: " + e.getMessage());
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof OcrTimeoutException) throw (OcrTimeoutException) cause;
if (cause instanceof OcrRuntimeException) throw (OcrRuntimeException) cause;
throw new OcrRuntimeException("OCR 执行异常: " + (cause == null ? e.getMessage() : cause.getMessage()), cause);
}
}
private List<OcrLine> doRecognize(Path imagePath) {
try {
BufferedImage img = ImageIO.read(imagePath.toFile());
if (img == null) return Collections.emptyList();
// 1. 检测
List<List<Float>> boxes = detector.detect(img);
// 2. 过滤 + 收集 crops (排除 height<8 或 width<5 的噪点 — 不会影响字段抽取)
List<BufferedImage> crops = new ArrayList<>();
List<List<Float>> validBoxes = new ArrayList<>();
for (List<Float> box : boxes) {
int[] bb = DbPostProcessor.bbox(box);
int x1 = Math.max(0, bb[0]);
int y1 = Math.max(0, bb[1]);
int x2 = Math.min(img.getWidth(), bb[2]);
int y2 = Math.min(img.getHeight(), bb[3]);
int w = x2 - x1, h = y2 - y1;
if (w < 5 || h < 8) continue; // 过小 — 噪点
if (w < 3 || h < 3) continue; // 原安全检查
crops.add(img.getSubimage(x1, y1, w, h));
validBoxes.add(box);
}
// 3. 批量推理 (性能关键: ONNX 一次推理处理所有 crop)
List<TextRecognizer.RecognizedText> rts = recognizer.recognizeBatch(crops);
// 4. 配对 boxes + texts
List<OcrLine> lines = new ArrayList<>();
for (int i = 0; i < validBoxes.size(); i++) {
TextRecognizer.RecognizedText rt = rts.get(i);
if (rt.text() != null && !rt.text().isEmpty()) {
List<Float> poly = validBoxes.get(i);
List<List<Float>> boxList = new ArrayList<>();
for (int k = 0; k < poly.size(); k += 2) {
List<Float> p = new ArrayList<>();
p.add(poly.get(k));
p.add(poly.get(k + 1));
boxList.add(p);
}
lines.add(new OcrLine(rt.text().trim(), rt.confidence(), boxList));
}
}
return lines;
} catch (OrtException | IOException e) {
throw new OcrRuntimeException("OCR 执行异常: " + e.getMessage(), e);
}
}
/** 内部异常, 避免暴露 ONNX 细节 */
public static class OcrRuntimeException extends RuntimeException {
public OcrRuntimeException(String message) { super(message); }
public OcrRuntimeException(String message, Throwable cause) { super(message, cause); }
}
}
@@ -0,0 +1,86 @@
package com.ruoyi.ocr.core;
import com.ruoyi.ocr.config.OcrProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.stereotype.Component;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* PDF → 图片 — 对齐 Python app.core.pdf_processor
* <p>
* 使用 PDFBox (无需 poppler 等系统依赖).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PdfProcessor {
private final OcrProperties props;
/**
* 把 PDF 每页渲染成 PNG, 返回临时文件路径列表.
* <p>
* 输出目录: {pdf.parent}/.{pdf.stem}_pages/page_{idx:03d}.png
*/
public List<Path> pdfToImages(Path pdfPath) throws IOException {
int dpi = props.getUpload().getPdfDpi();
Path outDir = pdfPath.getParent().resolve("." + stem(pdfPath) + "_pages");
outDir.toFile().mkdirs();
List<Path> saved = new ArrayList<>();
try (PDDocument doc = PDDocument.load(pdfPath.toFile())) {
PDFRenderer renderer = new PDFRenderer(doc);
renderer.setSubsamplingAllowed(true); // 大图降采样, 提速 + 省内存
int pageCount = doc.getNumberOfPages();
for (int idx = 0; idx < pageCount; idx++) {
BufferedImage img = renderer.renderImageWithDPI(idx, dpi);
Path outPath = outDir.resolve(String.format("page_%03d.png", idx + 1));
ImageProcessor.write(img, outPath);
saved.add(outPath);
}
}
log.info("PDF 转图片: {} → {} 页 (dpi={})", pdfPath.getFileName(), saved.size(), dpi);
return saved;
}
private static String stem(Path p) {
String name = p.getFileName().toString();
int dot = name.lastIndexOf('.');
return dot > 0 ? name.substring(0, dot) : name;
}
/**
* 抽取 PDF 内嵌文本(pdftotext 等价)— 用于"不调用 ONNX"的 fast path
* <p>
* 适用: 电子发票 / 数电票 PDF (含真实可复制文本层).
* 扫描件 PDF 抽出来为空或字符极少, 调用方应回退到 OCR 流程.
*
* @return 抽取到的纯文本 (trim 后); 若文件无文本层, 返回空字符串
*/
public String extractText(Path pdfPath) throws IOException {
try (PDDocument doc = PDDocument.load(pdfPath.toFile())) {
PDFTextStripper stripper = new PDFTextStripper();
// 逐页拼接
StringBuilder sb = new StringBuilder();
int pageCount = doc.getNumberOfPages();
for (int i = 1; i <= pageCount; i++) {
stripper.setStartPage(i);
stripper.setEndPage(i);
sb.append(stripper.getText(doc));
if (i < pageCount) sb.append('\n');
}
String text = sb.toString().trim();
log.info("PDF 内嵌文本抽取: {} → {} 字符 ({} 页)", pdfPath.getFileName(), text.length(), pageCount);
return text;
}
}
}
@@ -0,0 +1,156 @@
package com.ruoyi.ocr.core;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession;
import ai.onnxruntime.OrtSession.SessionOptions;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtSession.SessionOptions.ExecutionMode;
import ai.onnxruntime.OrtSession.SessionOptions.OptLevel;
import lombok.extern.slf4j.Slf4j;
import java.awt.image.BufferedImage;
import java.nio.FloatBuffer;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* PaddleOCR 检测 (DB 算法) — ONNX Runtime 推理.
* <p>
* 输入: [1, 3, H, W] 归一化图 (mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
* 输出: 概率图 [1, 1, H, W]
*/
@Slf4j
public class TextDetector implements AutoCloseable {
private final OrtEnvironment env;
private final OrtSession session;
private final float[] mean = {0.485f, 0.456f, 0.406f};
private final float[] std = {0.229f, 0.224f, 0.225f};
/** DB 二值化阈值 */
private final float dbThresh = 0.3f;
/** DB box 阈值 */
private final float boxThresh = 0.5f;
/** unclip 膨胀系数 */
private final float unclipRatio = 1.6f;
/** 最长边限制 — 通过构造参数传入 (v5 默认 800, v4 默认 960) */
private final int maxSideLen;
public TextDetector(OrtEnvironment env, Path modelPath, int maxSideLen) throws OrtException {
this.maxSideLen = maxSideLen;
this.env = env;
SessionOptions opts = new SessionOptions();
opts.setExecutionMode(ExecutionMode.PARALLEL);
opts.setOptimizationLevel(OptLevel.ALL_OPT);
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
opts.setIntraOpNumThreads(threads);
opts.setInterOpNumThreads(threads);
this.session = env.createSession(modelPath.toString(), opts);
log.info("DB 检测器加载完成: {}, threads={}", modelPath.getFileName(), threads);
}
/**
* 检测: 返回多边形 (每个 polygon 是 4-8 个 [x, y] 点)
*/
public List<List<Float>> detect(BufferedImage img) throws OrtException {
// 1. 预处理 (resize, pad, normalize)
int origH = img.getHeight();
int origW = img.getWidth();
// 按最长边缩放
int targetH = origH, targetW = origW;
int maxSide = Math.max(origH, origW);
if (maxSide > maxSideLen) {
float ratio = (float) maxSideLen / maxSide;
targetH = Math.round(origH * ratio);
targetW = Math.round(origW * ratio);
}
// pad 到 32 倍数
int padH = (32 - targetH % 32) % 32;
int padW = (32 - targetW % 32) % 32;
int inputH = targetH + padH;
int inputW = targetW + padW;
BufferedImage resized = resize(img, targetW, targetH);
float[] inputData = new float[3 * inputH * inputW];
// CHW 归一化 + pad
int[] pixels = new int[targetW * targetH];
resized.getRGB(0, 0, targetW, targetH, pixels, 0, targetW);
// CHW
for (int c = 0; c < 3; c++) {
for (int y = 0; y < targetH; y++) {
for (int x = 0; x < targetW; x++) {
int argb = pixels[y * targetW + x];
int v;
switch (c) {
case 0: v = (argb >> 16) & 0xFF; break;
case 1: v = (argb >> 8) & 0xFF; break;
default: v = argb & 0xFF;
}
int idx = c * inputH * inputW + y * inputW + x;
inputData[idx] = ((float) v / 255f - mean[c]) / std[c];
}
}
// pad 行: 已经是 0, 因为 FloatBuffer 默认 0
}
// 2. 推理
long[] shape = {1, 3, inputH, inputW};
OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape);
Map<String, OnnxTensor> inputs = Collections.singletonMap("x", inputTensor);
List<List<Float>> boxes;
try (OrtSession.Result result = session.run(inputs)) {
OnnxTensor outTensor = (OnnxTensor) result.get(0);
float[][] prob = extractProbMap(outTensor, inputH, inputW);
// 3. DB 后处理
boxes = DbPostProcessor.postProcess(prob, targetH, targetW, origH, origW,
dbThresh, boxThresh, unclipRatio);
} finally {
inputTensor.close();
}
return boxes;
}
/**
* 从 ONNX tensor 抽取概率图为 [H, W] float[][]
* <p>
* PaddleOCR 检测模型输出可能是 float[1][1][H][W] / float[1][H][W] / FloatBuffer 等.
*/
private static float[][] extractProbMap(OnnxTensor tensor, int h, int w) throws OrtException {
Object val = tensor.getValue();
if (val instanceof float[][][][]) {
return ((float[][][][]) val)[0][0];
} else if (val instanceof float[][][]) {
return ((float[][][]) val)[0];
} else if (val instanceof float[][]) {
return (float[][]) val;
} else if (val instanceof FloatBuffer) {
FloatBuffer buf = (FloatBuffer) val;
float[][] map = new float[h][w];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
map[y][x] = buf.get(y * w + x);
}
}
return map;
}
throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass()));
}
private static BufferedImage resize(BufferedImage src, int w, int h) {
java.awt.Image tmp = src.getScaledInstance(w, h, java.awt.Image.SCALE_SMOOTH);
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = dst.createGraphics();
g.drawImage(tmp, 0, 0, null);
g.dispose();
return dst;
}
@Override
public void close() throws OrtException {
session.close();
}
}
@@ -0,0 +1,181 @@
package com.ruoyi.ocr.core;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession;
import ai.onnxruntime.OrtSession.SessionOptions;
import ai.onnxruntime.OrtSession.SessionOptions.ExecutionMode;
import ai.onnxruntime.OrtSession.SessionOptions.OptLevel;
import lombok.extern.slf4j.Slf4j;
import java.awt.image.BufferedImage;
import java.nio.FloatBuffer;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* PaddleOCR 识别 (CRNN + CTC) — ONNX Runtime 推理.
* <p>
* 输入: [1, 3, H, W] 归一化图 (mean=0.5, std=0.5)
* 输出: [1, T, N] (T=序列长度, N=字典大小+1 含 blank)
* <p>
* 当前使用 PP-OCRv4 mobile: H=32, maxW=320 (v4 标准, v5 是 H=48).
*/
@Slf4j
public class TextRecognizer implements AutoCloseable {
private final OrtEnvironment env;
private final OrtSession session;
private final Dictionary dict;
private final float[] mean = {0.5f, 0.5f, 0.5f};
private final float[] std = {0.5f, 0.5f, 0.5f};
/** 输入最大宽 — 通过构造参数传入 */
private final int maxW;
/** 输入高度 — 通过构造参数传入 (v5=48, v4=32) */
private final int targetH;
public TextRecognizer(OrtEnvironment env, Path modelPath, Dictionary dict, int targetH, int maxW) throws OrtException {
this.env = env;
this.dict = dict;
this.targetH = targetH;
this.maxW = maxW;
SessionOptions opts = new SessionOptions();
// 并行执行模式 + 全图优化 (节点融合/常量折叠) — CRNN 是主要瓶颈, 收益最大
opts.setExecutionMode(ExecutionMode.PARALLEL);
opts.setOptimizationLevel(OptLevel.ALL_OPT);
// CRNN 单图推理 op 数少, intra-op 多核并行收益高; inter-op 多图并发 (无 batch 时无效)
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
opts.setIntraOpNumThreads(threads);
opts.setInterOpNumThreads(threads);
this.session = env.createSession(modelPath.toString(), opts);
log.info("CRNN 识别器加载完成: {}, 字典={} 字符, threads={}", modelPath.getFileName(), dict.size(), threads);
}
/**
* 识别一张裁剪图, 返回 (文本, 置信度)
*/
public RecognizedText recognize(BufferedImage crop) throws OrtException {
// 复用 batch 推理, 单图 = batch=1
List<RecognizedText> results = recognizeBatch(Collections.singletonList(crop));
return results.get(0);
}
/**
* 批量识别多张裁剪图 — 性能关键.
* <p>
* 核心优化: 把所有 crop pad 到 batch 内统一宽度 (maxW), 一次性喂给 ONNX.
* ONNX 内部用 8 线程并行算所有样本, 单图推理省掉 33 次 kernel launch.
* <p>
* 输入: 每张 crop 任意宽度 → resize 高 48 + 宽按比例 ≤ maxW
* 输出: 与输入 crops 一一对应的 RecognizedText
*/
public List<RecognizedText> recognizeBatch(List<BufferedImage> crops) throws OrtException {
int B = crops.size();
if (B == 0) return Collections.emptyList();
// targetH 来自构造参数 (v5=48, v4=32, 通过 OcrEngine 从配置读取)
// 1. 计算每张 crop 的目标宽度 + batch 内最大宽度
int[] targetWs = new int[B];
int batchMaxW = 0;
for (int i = 0; i < B; i++) {
BufferedImage c = crops.get(i);
float ratio = (float) targetH / c.getHeight();
int w = Math.min(maxW, Math.max(1, Math.round(c.getWidth() * ratio)));
// pad 到 8 倍数 (CRNN 下采样 8x)
w = ((w + 7) / 8) * 8;
targetWs[i] = w;
if (w > batchMaxW) batchMaxW = w;
}
// 2. 拼成 [B, 3, 48, batchMaxW] — 每张图只在 [0..targetW] 区间写, 其余为 0
float[] inputData = new float[B * 3 * targetH * batchMaxW];
for (int b = 0; b < B; b++) {
int w = targetWs[b];
if (w == 0) continue;
BufferedImage resized = resize(crops.get(b), w, targetH);
int[] pixels = new int[w * targetH];
resized.getRGB(0, 0, w, targetH, pixels, 0, w);
int bOffset = b * 3 * targetH * batchMaxW;
for (int c = 0; c < 3; c++) {
int cOffset = bOffset + c * targetH * batchMaxW;
int meanC = (int) (mean[c] * 255);
int stdC = (int) (std[c] * 255);
for (int y = 0; y < targetH; y++) {
int rowStart = cOffset + y * batchMaxW;
int pixRowStart = y * w;
for (int x = 0; x < w; x++) {
int argb = pixels[pixRowStart + x];
int v;
switch (c) {
case 0: v = (argb >> 16) & 0xFF; break;
case 1: v = (argb >> 8) & 0xFF; break;
default: v = argb & 0xFF;
}
// (v/255 - mean) / std, 避免浮点除法 (mean/std 都是 0.5)
inputData[rowStart + x] = (v - meanC) / (stdC * 1.0f);
}
}
}
}
// 3. 推理
long[] shape = {B, 3, targetH, batchMaxW};
OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape);
Map<String, OnnxTensor> inputs = Collections.singletonMap("x", inputTensor);
List<RecognizedText> results = new ArrayList<>(B);
try (OrtSession.Result result = session.run(inputs)) {
OnnxTensor outTensor = (OnnxTensor) result.get(0);
Object val = outTensor.getValue();
float[][][] logits3d;
if (val instanceof float[][][]) {
logits3d = (float[][][]) val;
} else if (val instanceof float[][][][]) {
logits3d = ((float[][][][]) val)[0];
} else {
throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass()));
}
for (int b = 0; b < B; b++) {
results.add(CtcDecoder.decode(logits3d[b], dict));
}
} finally {
inputTensor.close();
}
return results;
}
/**
* 从 ONNX tensor 抽取 logits 为 [T, N] float[][]
*/
private static float[][] extractLogits(OnnxTensor tensor) throws OrtException {
Object val = tensor.getValue();
if (val instanceof float[][][]) {
return ((float[][][]) val)[0];
} else if (val instanceof float[][]) {
return (float[][]) val;
}
throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass()));
}
private static BufferedImage resize(BufferedImage src, int w, int h) {
java.awt.Image tmp = src.getScaledInstance(w, h, java.awt.Image.SCALE_SMOOTH);
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = dst.createGraphics();
g.drawImage(tmp, 0, 0, null);
g.dispose();
return dst;
}
@Override
public void close() throws OrtException {
session.close();
}
/** 识别结果 */
public record RecognizedText(String text, double confidence) {}
}
@@ -0,0 +1,17 @@
package com.ruoyi.ocr.exception;
/**
* OCR 识别超时异常 — 对齐 Python app.core.ocr_engine.OCRTimeout
* <p>
* 单页 OCR 超时 / 整流程超时时抛出, 由 RecognizeService 转成 error_code="timeout".
*/
public class OcrTimeoutException extends RuntimeException {
public OcrTimeoutException(String message) {
super(message);
}
public OcrTimeoutException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,27 @@
package com.ruoyi.ocr.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 健康检查响应 — 对齐 Python app.models.schemas.HealthResponse
* <p>
* JSON 字段: status / version / engine_ready
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class HealthResponse {
/** "ok" / "degraded" */
private String status = "ok";
/** 服务版本号 */
private String version;
/** OCR 引擎是否就绪 */
@JsonProperty("engine_ready")
private Boolean engineReady;
}
@@ -0,0 +1,68 @@
package com.ruoyi.ocr.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 发票结构化字段 — 对齐 Python app.models.schemas.InvoiceFields
* <p>
* QR 权威字段 (QR 命中时覆盖 OCR 结果): invoice_no / invoice_date / amount
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InvoiceFields {
/** 发票类型, 如 增值税电子普通发票 / 增值税专用发票 / 数电票 */
@JsonProperty("invoice_type")
private String invoiceType;
/** 发票号码 — QR 权威 / OCR */
@JsonProperty("invoice_no")
private String invoiceNo;
/** 发票代码 — OCR (数电票此字段为空) */
@JsonProperty("invoice_code")
private String invoiceCode;
/** 开票日期 YYYY-MM-DD — QR 权威 / OCR */
@JsonProperty("invoice_date")
private String invoiceDate;
/** 价税合计 (小写) — QR 权威 / OCR */
private Double amount;
/** 价税合计 (大写中文) — OCR */
@JsonProperty("amount_cn")
private String amountCn;
/** 不含税金额 — OCR */
@JsonProperty("amount_pretax")
private Double amountPretax;
/** 税额 — OCR */
@JsonProperty("tax_amount")
private Double taxAmount;
/** 销售方名称 — OCR */
@JsonProperty("seller_name")
private String sellerName;
/** 销售方纳税人识别号 — OCR */
@JsonProperty("seller_tax_no")
private String sellerTaxNo;
/** 购买方名称 — OCR */
@JsonProperty("buyer_name")
private String buyerName;
/** 购买方纳税人识别号 — OCR */
@JsonProperty("buyer_tax_no")
private String buyerTaxNo;
/** 大写金额与小数金额一致性 (null=未能比对) */
@JsonProperty("amount_match")
private Boolean amountMatch;
}
@@ -0,0 +1,65 @@
package com.ruoyi.ocr.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* 发票识别主响应 — 对齐 Python app.models.schemas.InvoiceResult
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InvoiceResult {
/** 整体是否成功 */
private Boolean success;
/** 是否被判定为发票 (false = 非发票图片) */
@JsonProperty("is_invoice")
private Boolean isInvoice = true;
/** 全部 OCR 文本拼接 (快路径为 "[QR only] ...") */
@JsonProperty("raw_text")
private String rawText = "";
/** 分行识别结果 */
private List<OcrLine> lines = new ArrayList<>();
/** 抽取的结构化字段 */
private InvoiceFields fields = new InvoiceFields();
/** PDF 页数 / 图片 = 1 */
@JsonProperty("page_count")
private Integer pageCount = 1;
/** 使用的 OCR 引擎: "paddleocr" / "qr" */
private String engine = "paddleocr";
/** 识别耗时 (毫秒) */
@JsonProperty("elapsed_ms")
private Integer elapsedMs = 0;
/** 失败原因描述 */
private String error;
/** 错误码: not_invoice / timeout / unsupported / ocr_failed / process_failed */
@JsonProperty("error_code")
private String errorCode;
/** 是否从 QR 取到了 3 个核心字段 */
@JsonProperty("from_qr")
private Boolean fromQr = false;
/** 二维码原始文本 (排查用) */
@JsonProperty("qr_raw")
private String qrRaw;
/** 二维码识别失败原因 (no_qr / bad_format) */
@JsonProperty("qr_error")
private String qrError;
}
@@ -0,0 +1,36 @@
package com.ruoyi.ocr.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* 单行 OCR 识别结果 — 对齐 Python app.models.schemas.OCRLine
* <p>
* JSON 字段: text / confidence / box
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OcrLine {
/** 识别文本 */
private String text;
/** 置信度 0~1 */
private Double confidence;
/** 四点坐标 [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] */
@JsonProperty("box")
private List<List<Float>> box = new ArrayList<>();
public OcrLine(String text, Double confidence) {
this.text = text;
this.confidence = confidence;
this.box = new ArrayList<>();
}
}
@@ -0,0 +1,21 @@
package com.ruoyi.ocr.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 按文件路径识别的请求体 — 对齐 Python app.models.schemas.PathRecognizeRequest
* <p>
* 安全: 路径必须在 app.allowed-dirs 白名单内才会被执行.
* <p>
* JSON 字段名: file_path (snake_case, 与 Python 一致)
*/
@Data
public class PathRecognizeRequest {
/** 服务器本地绝对路径 (正反斜杠均可) */
@JsonProperty("file_path")
@NotBlank(message = "file_path 不能为空")
private String filePath;
}
@@ -0,0 +1,31 @@
package com.ruoyi.ocr.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 二维码识别结果 — 对齐 Python app.services.qr_decoder.QRDecodeResult
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class QrDecodeResult {
/** 发票号码 */
private String invoiceNo;
/** 金额 (小写) */
private Double amount;
/** 开票日期 YYYY-MM-DD */
private String invoiceDate;
/** 二维码原始文本 */
private String raw = "";
/** 是否有任一关键字段解出 */
public boolean hasAnyField() {
return invoiceNo != null || amount != null || invoiceDate != null;
}
}
@@ -0,0 +1,434 @@
package com.ruoyi.ocr.service;
import com.ruoyi.ocr.model.InvoiceFields;
import com.ruoyi.ocr.model.OcrLine;
import com.ruoyi.ocr.util.AmountUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 从 OCR 文本/行里抽取发票字段 — 对齐 Python app.services.invoice_extractor
* <p>
* 适配中国大陆 增值税发票(电子普票 / 专票 / 电子专票 / 数电票).
* <p>
* 关键策略:
* - 主体按 OCR box 坐标判断归属 (左右两栏)
* - 名称提取加 stop word, 避免单行文本混淆
* - 金额兜底: tax + pretax = total 组合搜索
*/
@Slf4j
@Service
public class InvoiceExtractor {
// ---------- 发票类型 (按长度降序, 优先匹配最长前缀, 避免 "电子发票(增值税专用发票)" 被 "增值税专用发票" 抢先命中) ----------
private static final List<String> INVOICE_TYPES = Arrays.asList(
"电子发票(增值税专用发票)", // 13
"电子发票(增值税普通发票)", // 13
"增值税电子专用发票", // 9
"增值税电子普通发票", // 9
"增值税专用发票", // 7
"增值税普通发票", // 7
"通用机打发票", // 6
"数电票(电子发票)", // 8
"数电票", // 3
"电子发票" // 4
);
// ---------- 发票号码 ----------
private static final Pattern NO_PATTERN = Pattern.compile(
"(?:发\\s*票\\s*号\\s*码|号\\s*码|No\\.?|号)\\s*[:]?\\s*(\\d{8,20})",
Pattern.CASE_INSENSITIVE
);
// ---------- 发票代码 ----------
private static final Pattern CODE_PATTERN = Pattern.compile(
"(?:发\\s*票\\s*代\\s*码|代\\s*码)\\s*[:]?\\s*(\\d{10,12}|\\d{8,12})"
);
// ---------- 开票日期 ----------
private static final Pattern DATE_PATTERN = Pattern.compile(
"(?:开\\s*票\\s*日\\s*期|日\\s*期)\\s*[:]?\\s*" +
"(\\d{4})\\s*[年/\\.]\\s*(\\d{1,2})\\s*[月/\\.]\\s*(\\d{1,2})"
);
// ---------- 纳税人识别号 (至少 1 个字母, 排除纯数字发票号) ----------
private static final Pattern TAX_NO_PATTERN = Pattern.compile("((?=[0-9A-Z]*[A-Z])[0-9A-Z]{18})");
// ---------- 主体标签 ----------
private static final Pattern BUYER_LABEL = Pattern.compile("\\s*买\\s*方\\s*(?:信\\s*息|名\\s*称|)");
private static final Pattern SELLER_LABEL = Pattern.compile("\\s*售\\s*方\\s*(?:信\\s*息|名\\s*称|)");
// ---------- 名称 (带 stop word 截断) ----------
private static final String NAME_STOP = "(?:销售方|购买方|统一社会信用|纳税人|项目名称|规格型号|^单位$|^数量$|^单价$|^金额|^税率|^税额|备注|收款人|复核|开票人|价税合计|小写|大写)";
private static final Pattern NAME_PATTERN = Pattern.compile(
"\\s*称\\s*[:]\\s*" +
"((?:(?!" + NAME_STOP + ")[^\\n\\r]){2,60}?(?:公司|商店|厂|店|部|中心|工作室))"
);
// 不依赖 "名称:" 前缀 — 用于 PDF 内嵌文本拆字版式 (label 和 value 分两段)
// 不强制 lookback 拒绝中文 (OCR 行可能整行连在一起如 "名称北京国钜...公司"), 靠 cleanName 截 stop word 过滤杂质
private static final Pattern COMPANY_PATTERN = Pattern.compile(
"([一-龥A-Za-z0-9()()·\\-]{2,30}(?:公司|商店|厂|店|部|中心|工作室))"
);
// ---------- 数字候选 ----------
private static final Pattern DECIMAL_PATTERN = Pattern.compile("(\\d+\\.\\d{2})");
/**
* 入口: 从文本 + (可选) 行列表抽取
*
* @param text OCR 全文
* @param lines OCR 行列表 (含 box)
* @param qrTotalAmount QR 解出的金额 (权威). 不为 null 时, 强制作为 total 用于 fallback 组合搜索.
*/
public InvoiceFields extract(String text, List<OcrLine> lines, Double qrTotalAmount) {
String norm = norm(text);
InvoiceFields fields = new InvoiceFields();
fields.setInvoiceType(detectInvoiceType(norm));
fields.setInvoiceCode(extractInvoiceCode(norm));
fields.setInvoiceNo(extractInvoiceNo(norm));
fields.setInvoiceDate(extractDate(norm));
// 金额 — 优先用 QR 提供的 total (权威, 数电票/电子发票的价税合计在 QR 里),
// QR 缺失时回退 OCR 的 TOTAL_PATTERN / 第一个数字
Double total;
if (qrTotalAmount != null) {
total = qrTotalAmount;
} else {
total = AmountUtils.extractTotalAmount(norm);
}
Double tax = AmountUtils.extractTaxAmount(norm);
Double pretax = AmountUtils.extractPretaxAmount(norm);
// 兜底 1: tax + pretax = total 组合搜索
if ((tax == null || pretax == null) && total != null) {
List<Double> candidates = new ArrayList<>();
Set<String> seen = new HashSet<>();
Matcher m = DECIMAL_PATTERN.matcher(norm);
while (m.find()) {
String s = m.group(1);
double v = Double.parseDouble(s);
if (v < total && seen.add(s)) {
candidates.add(v);
}
}
candidates.sort(Comparator.reverseOrder());
for (int i = 0; i < candidates.size(); i++) {
double a = candidates.get(i);
for (int j = i + 1; j < candidates.size(); j++) {
double b = candidates.get(j);
if (Math.abs(a + b - total) < 0.011) {
if (pretax == null) pretax = round2(a);
if (tax == null) tax = round2(b);
break;
}
}
if (tax != null && pretax != null) break;
}
// 兜底: 只剩一个候选
if ((tax == null || pretax == null) && candidates.size() == 1) {
double only = round2(candidates.get(0));
if (pretax == null && tax == null) {
pretax = only;
tax = round2(total - only);
} else if (tax == null) {
tax = only;
} else if (pretax == null) {
pretax = only;
}
}
}
// 兜底 2: total - 任一 = 另一
if (tax == null && total != null && pretax != null) {
tax = round2(total - pretax);
}
if (pretax == null && total != null && tax != null) {
pretax = round2(total - tax);
}
fields.setAmount(total);
fields.setTaxAmount(tax);
fields.setAmountPretax(pretax);
fields.setAmountCn(AmountUtils.extractCnAmount(norm));
fields.setAmountMatch(AmountUtils.amountConsistent(fields.getAmountCn(), fields.getAmount()));
// 主体
String sellerName, sellerTax, buyerName, buyerTax;
if (lines != null && !lines.isEmpty()) {
String[] parties = extractPartiesFromLines(lines);
sellerName = parties[0]; sellerTax = parties[1];
buyerName = parties[2]; buyerTax = parties[3];
if (!(sellerName != null && buyerName != null)) {
String[] textParties = extractPartiesFromText(norm);
sellerName = or(sellerName, textParties[0]);
sellerTax = or(sellerTax, textParties[1]);
buyerName = or(buyerName, textParties[2]);
buyerTax = or(buyerTax, textParties[3]);
}
} else {
String[] textParties = extractPartiesFromText(norm);
sellerName = textParties[0]; sellerTax = textParties[1];
buyerName = textParties[2]; buyerTax = textParties[3];
}
fields.setSellerName(sellerName);
fields.setSellerTaxNo(sellerTax);
fields.setBuyerName(buyerName);
fields.setBuyerTaxNo(buyerTax);
return fields;
}
private static String or(String a, String b) {
return a != null ? a : b;
}
private static double round2(double v) {
return Math.round(v * 100.0) / 100.0;
}
private static String norm(String text) {
return text == null ? "" : text.replaceAll("\\s+", " ").trim();
}
private static String detectInvoiceType(String text) {
for (String t : INVOICE_TYPES) {
if (text.contains(t)) return t;
}
return null;
}
private static String extractInvoiceNo(String text) {
Matcher m = NO_PATTERN.matcher(text);
return m.find() ? m.group(1) : null;
}
private static String extractInvoiceCode(String text) {
Matcher m = CODE_PATTERN.matcher(text);
return m.find() ? m.group(1) : null;
}
private static String extractDate(String text) {
Matcher m = DATE_PATTERN.matcher(text);
if (!m.find()) return null;
int y = Integer.parseInt(m.group(1));
int mo = Integer.parseInt(m.group(2));
int d = Integer.parseInt(m.group(3));
return String.format("%04d-%02d-%02d", y, mo, d);
}
private static String cleanName(String name) {
if (name == null) return null;
String s = name;
// OCR 容易把 "名称北京国钜..." 整段匹出来 (名称 是字符类里的字), 剥掉前缀保留公司名
for (String prefix : new String[]{"名称", "购买方", "销售方", "买方", "卖方", "购方", "销方"}) {
if (s.startsWith(prefix)) s = s.substring(prefix.length());
}
// 截断 stop word (label 残留: OCR/PDF 都可能把 label 跟 value 拼在一起)
for (String stop : new String[]{"纳税人", "统一社会", "购买方", "销售方",
"项目名称", "规格型号", "单价", "数量", "金额", "税率", "税额",
"价税合计", "大写", "小写", "备注", "收款", "复核", "开票"}) {
int idx = s.indexOf(stop);
if (idx >= 0) s = s.substring(0, idx);
}
// 去掉前导符号
s = s.replaceAll("^[\\s:,,。、]+", "");
// 只保留中文/字母/数字/()/-/·
s = s.replaceAll("[^一-龥A-Za-z0-9()()·\\-]", "");
s = s.replaceAll("[:;,,。、 ]+$", "").trim();
return s.isEmpty() ? null : s;
}
/**
* box: [[x1,y1], ...] → (cx, cy)
*/
private static double[] boxCenter(List<List<Float>> box) {
if (box == null || box.size() < 4) return new double[]{0, 0};
double minX = Double.MAX_VALUE, maxX = -Double.MAX_VALUE;
double minY = Double.MAX_VALUE, maxY = -Double.MAX_VALUE;
for (List<Float> p : box) {
if (p.size() < 2) continue;
double x = p.get(0), y = p.get(1);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
return new double[]{(minX + maxX) / 2, (minY + maxY) / 2};
}
/**
* 返回 [seller_name, seller_tax, buyer_name, buyer_tax]
* <p>
* 策略:
* - byX 分支 (左右栏): 用 buyer/seller 标签的 x 坐标分栏. **用 COMPANY_PATTERN** (不依赖"名称:"前缀).
* - 上下栏 / 单栏分支: 按 y 排序, **第一个 = 销售方** (中国数电票/电子专票版式 — 销方先印).
*/
private String[] extractPartiesFromLines(List<OcrLine> lines) {
List<List<Float>> buyerLabelBox = null, sellerLabelBox = null;
for (OcrLine line : lines) {
if (buyerLabelBox == null && BUYER_LABEL.matcher(line.getText() != null ? line.getText() : "").find()) {
buyerLabelBox = line.getBox();
}
if (sellerLabelBox == null && SELLER_LABEL.matcher(line.getText() != null ? line.getText() : "").find()) {
sellerLabelBox = line.getBox();
}
}
boolean byX = buyerLabelBox != null && sellerLabelBox != null
&& Math.abs(boxCenter(buyerLabelBox)[0] - boxCenter(sellerLabelBox)[0]) > 50;
String sellerName = null, sellerTax = null, buyerName = null, buyerTax = null;
if (byX) {
double[] bc = boxCenter(buyerLabelBox);
double[] sc = boxCenter(sellerLabelBox);
double mid = (bc[0] + sc[0]) / 2;
for (OcrLine line : lines) {
if (line.getText() == null) continue;
// 用 COMPANY_PATTERN 替代 NAME_PATTERN — 不依赖 "名称:" 前缀, 对拆字/拼接行更鲁棒
Matcher nm = COMPANY_PATTERN.matcher(line.getText());
if (nm.find()) {
double cx = boxCenter(line.getBox())[0];
String cleaned = cleanName(nm.group(1));
if (cleaned == null || containsStopWord(cleaned)) continue;
if (cx < mid && buyerName == null) buyerName = cleaned;
else if (cx >= mid && sellerName == null) sellerName = cleaned;
}
Matcher tm = TAX_NO_PATTERN.matcher(line.getText());
if (tm.find()) {
double cx = boxCenter(line.getBox())[0];
String tax = tm.group(1);
if (cx < mid && buyerTax == null) buyerTax = tax;
else if (cx >= mid && sellerTax == null) sellerTax = tax;
}
}
} else {
// 没找到 buyer/seller 标签 box → 自动检测栏位 (左右栏 vs 上下栏)
// 收集所有 (name, cx, cy) 和 (tax, cx, cy) 候选
List<double[]> nameCoords = new ArrayList<>(); // [cx, cy]
List<String> nameVals = new ArrayList<>();
List<double[]> taxCoords = new ArrayList<>();
List<String> taxVals = new ArrayList<>();
for (OcrLine line : lines) {
if (line.getText() == null) continue;
double[] c = boxCenter(line.getBox());
Matcher nm = COMPANY_PATTERN.matcher(line.getText());
if (nm.find()) {
String cleaned = cleanName(nm.group(1));
log.info("DEBUG company match: text={} match={} cleaned={} stopWord={}",
line.getText(), nm.group(1), cleaned, containsStopWord(cleaned));
if (cleaned != null && !containsStopWord(cleaned)) {
nameCoords.add(c);
nameVals.add(cleaned);
}
}
Matcher tm = TAX_NO_PATTERN.matcher(line.getText());
if (tm.find()) {
taxCoords.add(c);
taxVals.add(tm.group(1));
}
}
// 自动判断: 前两个 name 的 |dy| < 30 且 |dx| > 200 → 左右栏
boolean leftRight = false;
if (nameCoords.size() >= 2) {
double dx = Math.abs(nameCoords.get(0)[0] - nameCoords.get(1)[0]);
double dy = Math.abs(nameCoords.get(0)[1] - nameCoords.get(1)[1]);
leftRight = dy < 30 && dx > 200;
}
if (leftRight) {
// 左右栏: 按 x 排序, **x 小 = 买方 (购买方在左), x 大 = 卖方 (销售方在右)**
sortByFirstCoord(nameCoords, nameVals, 0);
sortByFirstCoord(taxCoords, taxVals, 0);
} else {
// 上下栏: 按 y 排序, **第一个 = 买方** (国家税务总局标准: 购方信息在前)
sortByFirstCoord(nameCoords, nameVals, 1);
sortByFirstCoord(taxCoords, taxVals, 1);
}
if (nameVals.size() > 0) buyerName = nameVals.get(0);
if (taxVals.size() > 0) buyerTax = taxVals.get(0);
if (nameVals.size() > 1) sellerName = nameVals.get(1);
if (taxVals.size() > 1) sellerTax = taxVals.get(1);
}
return new String[]{sellerName, sellerTax, buyerName, buyerTax};
}
/**
* 按指定坐标 (0=x, 1=y) 同步排序坐标和值列表
*/
private static void sortByFirstCoord(List<double[]> coords, List<String> vals, int dim) {
List<Integer> idx = new ArrayList<>();
for (int i = 0; i < coords.size(); i++) idx.add(i);
idx.sort(Comparator.comparingDouble(i -> coords.get(i)[dim]));
List<double[]> sc = new ArrayList<>();
List<String> sv = new ArrayList<>();
for (int i : idx) {
sc.add(coords.get(i));
sv.add(vals.get(i));
}
coords.clear(); coords.addAll(sc);
vals.clear(); vals.addAll(sv);
}
/**
* 无 box 信息时的 fallback: 全局收集 name + tax, 按出现顺序配对.
* <p>
* 中国电子发票版式 (数电票 / 电子专票): 国家税务总局标准 — **购买方信息在前(左/上), 销售方信息在后(右/下)**.
* PDF 文字流的 value 顺序通常按视觉顺序 (左/上 先), 所以:
* **第一个匹配 = 购买方, 第二个匹配 = 销售方**.
* <p>
* 修复:
* 1. PDF 内嵌文本拆字版式 ("购/买/方/信/息") — 旧 "在标签后 200 字符找 name" 失效 (label/value 分段)
* 2. 用 COMPANY_PATTERN (不依赖 "名称:" 前缀) 替代 NAME_PATTERN
* 3. 第一个 = 买方 (国家税务总局标准: 购方信息在前)
*
* @return [seller_name, seller_tax, buyer_name, buyer_tax]
*/
private String[] extractPartiesFromText(String text) {
// 1. 全局收集所有公司名 (按出现顺序, 去重叠, 去 stop word)
List<String> cleanedNames = new ArrayList<>();
Matcher nm = COMPANY_PATTERN.matcher(text);
int lastEnd = -1;
while (nm.find()) {
if (nm.start() < lastEnd) continue; // 去重叠
String c = cleanName(nm.group(1));
if (c != null && !containsStopWord(c)) cleanedNames.add(c);
lastEnd = nm.end();
}
// 2. 全局收集所有税号 (按出现顺序)
List<String> taxes = new ArrayList<>();
Matcher tm = TAX_NO_PATTERN.matcher(text);
while (tm.find()) taxes.add(tm.group(1));
// 3. 配对 (按位置一一对应): name[0]<->tax[0] = 买方, name[1]<->tax[1] = 卖方
String buyerName = cleanedNames.size() > 0 ? cleanedNames.get(0) : null;
String buyerTax = taxes.size() > 0 ? taxes.get(0) : null;
String sellerName = cleanedNames.size() > 1 ? cleanedNames.get(1) : null;
String sellerTax = taxes.size() > 1 ? taxes.get(1) : null;
return new String[]{sellerName, sellerTax, buyerName, buyerTax};
}
/**
* 公司名 stop word 过滤 (cleanName 已经过滤一些, 这里再覆盖):
* 表格项目名 ("项目名称"), 规格型号 ("规格"), 备注 ("备注"), 单位, 数量 等.
* <p>
* 注意: "名称" 不在这里过滤 — cleanName 会截断 "名称" 前缀; 如果 cleanName 截断后还是包含 "名称"
* 才在这里过滤 (防止截断失败导致整个公司名被拒).
*/
private static boolean containsStopWord(String s) {
if (s == null) return true;
// 只过滤明显不是公司名的杂质 (cleanName 已经处理过大部分 stop word)
return s.contains("价税合计") || s.contains("大写") || s.contains("小写")
|| s.contains("项目名称") || s.contains("规格型号")
|| s.equals("公司") || s.length() < 4;
}
}
@@ -0,0 +1,159 @@
package com.ruoyi.ocr.service;
import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import com.ruoyi.ocr.model.QrDecodeResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Path;
/**
* 电子发票二维码识别 — 对齐 Python app.services.qr_decoder
* <p>
* 国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔):
* 01,&lt;type&gt;,&lt;invoice_code&gt;,&lt;invoice_no&gt;,&lt;amount&gt;,&lt;date&gt;,&lt;check_code&gt;,&lt;reserved&gt;
* <p>
* 例: 01,31,,24922000000006110014,39500.00,20240202,,A371
* <p>
* 使用 ZXing (纯 Java, 无需 opencv).
*/
@Slf4j
@Service
public class QrDecoder {
private static final MultiFormatReader ZXING_READER = new MultiFormatReader();
/**
* 从图片文件解电子发票二维码
*/
public QrDecodeResult decodeQr(Path imagePath) {
String raw;
try {
raw = detectQr(imagePath);
} catch (Exception e) {
log.warn("QR 检测异常 {}: {}", imagePath.getFileName(), e.getMessage());
return new QrDecodeResult();
}
if (raw == null || raw.isEmpty()) {
return new QrDecodeResult();
}
QrDecodeResult parsed = parsePayload(raw);
if (parsed.hasAnyField()) {
log.info("QR 解码成功: no={}, amt={}, date={}",
parsed.getInvoiceNo(), parsed.getAmount(), parsed.getInvoiceDate());
} else {
log.debug("QR 解出但字段无效: raw={}", raw.substring(0, Math.min(80, raw.length())));
}
return parsed;
}
/**
* 全图 → 4 象限 → 2x 放大, 任一命中即返回.
*/
private String detectQr(Path imagePath) throws IOException {
BufferedImage img = ImageIO.read(imagePath.toFile());
if (img == null) {
return "";
}
// 1) 全图
String txt = tryDecode(img);
if (txt != null && !txt.isEmpty()) {
return txt;
}
// 2) 四象限
int h = img.getHeight();
int w = img.getWidth();
BufferedImage[][] crops = {
{img.getSubimage(0, 0, w / 2, h / 2), img.getSubimage(w / 2, 0, w - w / 2, h / 2)},
{img.getSubimage(0, h / 2, w / 2, h - h / 2), img.getSubimage(w / 2, h / 2, w - w / 2, h - h / 2)}
};
String[] names = {"left-top", "right-top", "left-bottom", "right-bottom"};
int idx = 0;
for (BufferedImage[] row : crops) {
for (BufferedImage crop : row) {
txt = tryDecode(crop);
if (txt != null && !txt.isEmpty()) {
log.debug("QR found in {}", names[idx]);
return txt;
}
idx++;
}
}
// 3) 2x 放大 (二维码像素过小的情况)
BufferedImage scaled = resize(img, 2.0);
return tryDecode(scaled);
}
private String tryDecode(BufferedImage img) {
try {
LuminanceSource source = new BufferedImageLuminanceSource(img);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
Result result = ZXING_READER.decode(bitmap);
return result != null ? result.getText() : "";
} catch (NotFoundException e) {
return "";
} catch (Exception e) {
log.debug("ZXing decode error: {}", e.getMessage());
return "";
}
}
private static BufferedImage resize(BufferedImage src, double scale) {
int w = (int) (src.getWidth() * scale);
int h = (int) (src.getHeight() * scale);
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.drawImage(src, 0, 0, w, h, null);
g.dispose();
return dst;
}
/**
* 解析电子发票二维码内容 → QrDecodeResult
* <p>
* 字段全 null 表示无二维码或格式不正确.
*/
private QrDecodeResult parsePayload(String raw) {
if (raw == null || raw.isEmpty()) {
return new QrDecodeResult();
}
String[] parts = raw.split(",", -1);
if (parts.length != 8) {
log.debug("QR 字段数 {} != 8, 视为格式不正确", parts.length);
QrDecodeResult r = new QrDecodeResult();
r.setRaw(raw);
return r;
}
QrDecodeResult result = new QrDecodeResult();
result.setRaw(raw);
// parts[3] = 发票号
String invoiceNo = parts[3].trim();
if (!invoiceNo.isEmpty() && invoiceNo.length() >= 10 && invoiceNo.length() <= 30) {
result.setInvoiceNo(invoiceNo);
}
// parts[4] = 金额
String amtStr = parts[4].trim();
if (!amtStr.isEmpty()) {
try {
result.setAmount(Math.round(Double.parseDouble(amtStr) * 100.0) / 100.0);
} catch (NumberFormatException ignored) {}
}
// parts[5] = 开票日期 (YYYYMMDD)
String dateStr = parts[5].trim();
if (dateStr.length() == 8 && dateStr.chars().allMatch(Character::isDigit)) {
result.setInvoiceDate(dateStr.substring(0, 4) + "-" + dateStr.substring(4, 6) + "-" + dateStr.substring(6, 8));
}
return result;
}
}
@@ -0,0 +1,312 @@
package com.ruoyi.ocr.service;
import com.ruoyi.ocr.config.OcrProperties;
import com.ruoyi.ocr.core.ImageProcessor;
import com.ruoyi.ocr.core.OcrEngine;
import com.ruoyi.ocr.core.PdfProcessor;
import com.ruoyi.ocr.exception.OcrTimeoutException;
import com.ruoyi.ocr.model.InvoiceFields;
import com.ruoyi.ocr.model.InvoiceResult;
import com.ruoyi.ocr.model.OcrLine;
import com.ruoyi.ocr.model.QrDecodeResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 端到端识别流水: 文件 → QR → (可选)OCR → 字段抽取 → InvoiceResult
* <p>
* 完全对齐 Python app.services.recognize_service:
* <pre>
* 1. PDF / 图片 → BufferedImage 列表 (page_count)
* 2. qr = decode_qr(page[0])
* 3. qr_ok && !qr_full_ocr → [QR only] 快路径返回
* 4. !qr_ok → not_invoice, 不跑 OCR
* 5. for each page:
* if elapsed > total_deadline → timeout
* try _ocr_image(page) with page_timeout
* 6. extract_invoice(raw_text, lines)
* 7. overlay_qr_fields(qr) ← QR 3 字段覆盖 OCR 结果
* 8. return InvoiceResult
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RecognizeService {
private static final Set<String> IMG_EXTS = Set.of(".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff");
private final OcrProperties props;
private final PdfProcessor pdfProcessor;
private final OcrEngine ocrEngine;
private final QrDecoder qrDecoder;
private final InvoiceExtractor extractor;
/**
* 入口: 上传文件字节流
*/
public InvoiceResult recognizeFile(String filename, byte[] content) {
long t0 = System.currentTimeMillis();
String suffix = filename == null ? ".bin" : ext(filename);
Path tmp = saveUpload(content, suffix);
try {
return recognizePath(tmp, true);
} finally {
// 已在 recognizePath finally 里删, 这里兜底
try { Files.deleteIfExists(tmp); } catch (IOException ignored) {}
log.info("总耗时 {}ms", System.currentTimeMillis() - t0);
}
}
/**
* 入口: 服务器本地路径 (已在白名单校验)
*/
public InvoiceResult recognizePath(Path filePath, boolean deleteAfter) {
long t0 = System.currentTimeMillis();
String suffix = ext(filePath.getFileName().toString());
long totalDeadline = t0 + props.getOcr().getTotalTimeoutS() * 1000L;
try {
// 1. PDF / 图片 → 临时图片列表
List<Path> pageImgs;
try {
if (suffix.equals(".pdf")) {
pageImgs = pdfProcessor.pdfToImages(filePath);
} else if (IMG_EXTS.contains(suffix)) {
pageImgs = List.of(filePath);
} else {
return err("不支持的文件类型: " + suffix + "(仅支持 PDF / 图片)",
"unsupported", false, 1, t0);
}
} catch (Exception e) {
log.error("PDF/图片处理失败: {}", e.getMessage(), e);
return err("PDF/图片处理失败: " + e.getMessage(),
"process_failed", false, 1, t0);
}
// 2. QR 优先识别
QrDecodeResult qr = qrDecoder.decodeQr(pageImgs.get(0));
boolean qrOk = qr.hasAnyField();
// 3. 快路径: QR 命中且 fast 模式
if (qrOk && !props.getOcr().isQrFullOcr()) {
log.info("QR 快路径: {} 耗时 {}ms", filePath.getFileName(), elapsedMs(t0));
return buildQrOnlyResult(qr, elapsedMs(t0), pageImgs.size());
}
// 4. 无有效 QR → 非发票, 不跑 OCR
if (!qrOk) {
String reason = qr.getRaw() == null || qr.getRaw().isEmpty()
? "未识别到发票二维码"
: "二维码格式不合法";
log.info("非发票 (无有效 QR): {} 耗时 {}ms", filePath.getFileName(), elapsedMs(t0));
InvoiceResult r = err(reason + "(可能不是发票图片)",
"not_invoice", false, pageImgs.size(), t0);
r.setFromQr(false);
r.setQrRaw(qr.getRaw() == null || qr.getRaw().isEmpty() ? null : qr.getRaw());
r.setQrError(qr.getRaw() == null || qr.getRaw().isEmpty() ? "no_qr" : "bad_format");
return r;
}
// 4.5 PDF 内嵌文本 fast path (跳过 ONNX, 直接 PDFTextStripper)
// - 适用电子发票 / 数电票 PDF (含真实文本层)
// - 扫描件 PDF 抽不到文本, 自动回退到 ONNX
if (suffix.equals(".pdf") && props.getOcr().isUsePdfTextFirst()) {
try {
String pdfText = pdfProcessor.extractText(filePath);
int minChars = props.getOcr().getPdfTextMinChars();
if (pdfText.length() >= minChars) {
int elapsed = elapsedMs(t0);
log.info("PDF 内嵌文本 fast path: {} chars={}, 跳过 ONNX, 耗时 {}ms",
filePath.getFileName(), pdfText.length(), elapsed);
InvoiceFields fields = extractor.extract(pdfText, java.util.Collections.emptyList(), qr.getAmount());
fields = overlayQrFields(fields, qr);
InvoiceResult r = new InvoiceResult();
r.setSuccess(true);
r.setIsInvoice(true);
r.setRawText(pdfText);
r.setFields(fields);
r.setPageCount(pageImgs.size());
r.setEngine("pdftxt");
r.setElapsedMs(elapsed);
r.setFromQr(true);
r.setQrRaw(qr.getRaw());
return r;
}
log.info("PDF 内嵌文本太短 ({} 字符 < {}), 回退 ONNX", pdfText.length(), minChars);
} catch (Exception e) {
log.warn("PDF 内嵌文本抽取失败, 回退 ONNX: {}", e.getMessage());
}
}
// 5. 每页 OCR
List<OcrLine> allLines = new ArrayList<>();
for (int idx = 0; idx < pageImgs.size(); idx++) {
long remaining = totalDeadline - System.currentTimeMillis();
if (remaining <= 0) {
log.warn("达到总超时 ({}s), 中断 OCR", props.getOcr().getTotalTimeoutS());
InvoiceResult r = err(String.format("达到总超时 (%d秒), 已识别 %d/%d 页",
props.getOcr().getTotalTimeoutS(), idx, pageImgs.size()),
"timeout", false, pageImgs.size(), t0);
r.setRawText(joinLines(allLines));
r.setLines(allLines);
r.setFromQr(true);
r.setQrRaw(qr.getRaw());
return r;
}
int pageTimeout = (int) Math.min(props.getOcr().getPageTimeoutS(), remaining / 1000.0);
try {
allLines.addAll(ocrImage(pageImgs.get(idx)));
} catch (OcrTimeoutException e) {
log.warn("第 {} 页 OCR 超时: {}", idx + 1, e.getMessage());
InvoiceResult r = err(String.format("第 %d 页识别超时 (%.1f秒)",
idx + 1, pageTimeout * 1.0),
"timeout", false, pageImgs.size(), t0);
r.setRawText(joinLines(allLines));
r.setLines(allLines);
r.setFromQr(true);
r.setQrRaw(qr.getRaw());
return r;
} catch (Exception e) {
log.error("第 {} 页 OCR 失败: {}", idx + 1, e.getMessage(), e);
InvoiceResult r = err(String.format("第 %d 页识别失败: %s", idx + 1, e.getMessage()),
"ocr_failed", false, pageImgs.size(), t0);
r.setRawText(joinLines(allLines));
r.setLines(allLines);
r.setFromQr(true);
r.setQrRaw(qr.getRaw());
return r;
}
}
// 6. 字段抽取 (QR total 优先) + QR 字段覆盖
String rawText = joinLines(allLines);
// 把 QR 的 amount 提前传给 extractor, 让 fallback 组合搜索能用对的总价.
InvoiceFields fields = extractor.extract(rawText, allLines, qr.getAmount());
fields = overlayQrFields(fields, qr);
int elapsed = elapsedMs(t0);
log.info("识别完成: {} 页={}, from_qr=true, 耗时={}ms", filePath.getFileName(), pageImgs.size(), elapsed);
InvoiceResult r = new InvoiceResult();
r.setSuccess(true);
r.setIsInvoice(true);
r.setRawText(rawText);
r.setLines(allLines);
r.setFields(fields);
r.setPageCount(pageImgs.size());
r.setEngine("paddleocr");
r.setElapsedMs(elapsed);
r.setFromQr(true);
r.setQrRaw(qr.getRaw());
return r;
} finally {
if (deleteAfter) {
try {
Files.deleteIfExists(filePath);
if (suffix.equals(".pdf")) {
// 清理 .xxx_pages/ 临时目录
Path pagesDir = filePath.getParent().resolve("." + stem(filePath) + "_pages");
if (Files.exists(pagesDir)) {
try (var stream = Files.walk(pagesDir)) {
stream.sorted((a, b) -> b.compareTo(a)).forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
}
}
}
} catch (Exception ignored) {}
}
}
}
// ---------- 内部 ----------
private List<OcrLine> ocrImage(Path imgPath) {
Path rotated = ImageProcessor.autoRotate(imgPath);
Path enhanced = ImageProcessor.enhance(rotated);
return ocrEngine.recognize(enhanced);
}
private static String joinLines(List<OcrLine> lines) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines.size(); i++) {
if (i > 0) sb.append('\n');
sb.append(lines.get(i).getText());
}
return sb.toString();
}
private static String ext(String filename) {
int dot = filename.lastIndexOf('.');
return dot >= 0 ? filename.substring(dot).toLowerCase() : "";
}
private static String stem(Path p) {
String name = p.getFileName().toString();
int dot = name.lastIndexOf('.');
return dot > 0 ? name.substring(0, dot) : name;
}
private static Path saveUpload(byte[] content, String suffix) {
try {
Path tmp = Files.createTempFile("ry_ocr_", suffix);
Files.write(tmp, content);
return tmp;
} catch (IOException e) {
throw new RuntimeException("保存临时文件失败: " + e.getMessage(), e);
}
}
private static int elapsedMs(long t0) {
return (int) (System.currentTimeMillis() - t0);
}
private InvoiceResult err(String error, String errorCode, boolean isInvoice, int pageCount, long t0) {
InvoiceResult r = new InvoiceResult();
r.setSuccess(false);
r.setIsInvoice(isInvoice);
r.setError(error);
r.setErrorCode(errorCode);
r.setPageCount(pageCount);
r.setEngine("paddleocr");
r.setElapsedMs(elapsedMs(t0));
return r;
}
private InvoiceResult buildQrOnlyResult(QrDecodeResult qr, int elapsedMs, int pageCount) {
InvoiceFields fields = new InvoiceFields();
fields.setInvoiceNo(qr.getInvoiceNo());
fields.setAmount(qr.getAmount());
fields.setInvoiceDate(qr.getInvoiceDate());
InvoiceResult r = new InvoiceResult();
r.setSuccess(true);
r.setIsInvoice(true);
r.setRawText("[QR only] " + qr.getRaw());
r.setFields(fields);
r.setPageCount(pageCount);
r.setEngine("qr");
r.setElapsedMs(elapsedMs);
r.setFromQr(true);
r.setQrRaw(qr.getRaw());
return r;
}
/**
* QR 解出的 3 字段优先, 没解到的保持 OCR 结果
*/
private InvoiceFields overlayQrFields(InvoiceFields fields, QrDecodeResult qr) {
if (qr.getInvoiceNo() != null) fields.setInvoiceNo(qr.getInvoiceNo());
if (qr.getAmount() != null) fields.setAmount(qr.getAmount());
if (qr.getInvoiceDate() != null) fields.setInvoiceDate(qr.getInvoiceDate());
return fields;
}
}
@@ -0,0 +1,296 @@
package com.ruoyi.ocr.util;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 金额工具: 中文大写金额解析 + 小写金额正则 — 对齐 Python app.utils.amount_utils
* <p>
* 中文大写金额解析是 cn2an smart 模式的简化版:
* - 支持字符: 零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整
* - 处理 亿/万/元 三段累计, 段内 仟佰拾 累加
* - 处理 角分 (0.1 / 0.01)
*/
public final class AmountUtils {
private AmountUtils() {}
// ---------- 小写金额正则 ----------
/** 通用金额: ¥1,234.56 或 1234.56 */
private static final Pattern NUM_PATTERN = Pattern.compile("¥?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
/** 税额: "税额 ¥12.34" / "税额:12.34" */
private static final Pattern TAX_AMOUNT_PATTERN = Pattern.compile("\\s*额\\s*[¥:]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
/** 不含税: "不含税价 ¥1234.56" / "不含税:1234.56" */
private static final Pattern PRETAX_PATTERN = Pattern.compile("(?:不合?税价|不含税)\\s*[¥:]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
/** 价税合计: "价税合计 ¥1234.56" */
private static final Pattern TOTAL_PATTERN = Pattern.compile("价税合计[^\\d]*[¥]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})");
// ---------- 中文大写金额正则 ----------
private static final Pattern CN_IN_PARENS_AFTER_TOTAL = Pattern.compile(
"价税合计[^\\(]*[\\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]+)[\\)]"
);
private static final Pattern CN_IN_ANY_PARENS = Pattern.compile(
"[\\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30})[\\)]"
);
private static final Pattern CN_LONG = Pattern.compile("[零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30}");
// ---------- 数字映射 ----------
private static final Map<Character, Integer> DIGIT_MAP = new HashMap<>();
private static final Map<Character, Double> UNIT_MAP = new HashMap<>();
private static final Map<Character, Double> BIG_UNIT_MAP = new HashMap<>();
static {
DIGIT_MAP.put('零', 0);
DIGIT_MAP.put('壹', 1);
DIGIT_MAP.put('贰', 2);
DIGIT_MAP.put('叁', 3);
DIGIT_MAP.put('肆', 4);
DIGIT_MAP.put('伍', 5);
DIGIT_MAP.put('陆', 6);
DIGIT_MAP.put('柒', 7);
DIGIT_MAP.put('捌', 8);
DIGIT_MAP.put('玖', 9);
UNIT_MAP.put('拾', 10.0);
UNIT_MAP.put('佰', 100.0);
UNIT_MAP.put('仟', 1000.0);
BIG_UNIT_MAP.put('角', 0.1);
BIG_UNIT_MAP.put('分', 0.01);
}
// ---------- 公开方法 ----------
/**
* 从文本里抽取中文大写金额
* <p>
* 优先级:
* 1. "价税合计" 后面括号内
* 2. 任意中括号里的中文金额
* 3. 含"元"或"圆"的最长中文字符串
*/
public static String extractCnAmount(String text) {
if (text == null || text.isEmpty()) {
return null;
}
Matcher m = CN_IN_PARENS_AFTER_TOTAL.matcher(text);
if (m.find()) {
return m.group(1);
}
m = CN_IN_ANY_PARENS.matcher(text);
if (m.find()) {
return m.group(1);
}
Matcher m2 = CN_LONG.matcher(text);
while (m2.find()) {
String cand = m2.group();
if (cand.contains("") || cand.contains("")) {
return cand;
}
}
return null;
}
/**
* 中文大写金额 → float, 例如 "贰佰元整" → 200.0
*/
public static Double parseCnAmount(String cnText) {
if (cnText == null || cnText.isEmpty()) {
return null;
}
try {
String s = normalizeCnAmount(cnText);
s = s.replaceAll("整$", "");
if (!s.endsWith("")) {
s = s + "";
}
return smartParse(s);
} catch (Exception e) {
return null;
}
}
/**
* 大写 vs 小写金额比对
*/
public static Boolean amountConsistent(String cnText, Double numAmount) {
if (cnText == null || numAmount == null) {
return null;
}
Double cnValue = parseCnAmount(cnText);
if (cnValue == null) {
return null;
}
return Math.abs(cnValue - numAmount) < 0.011;
}
/**
* 抽取第一个形如 1234.56 或 ¥1,234.56 的金额
*/
public static Double extractNumAmount(String text) {
if (text == null || text.isEmpty()) {
return null;
}
Matcher m = NUM_PATTERN.matcher(text);
if (!m.find()) {
return null;
}
try {
return Double.parseDouble(m.group(1).replace(",", ""));
} catch (NumberFormatException e) {
return null;
}
}
/**
* 抽取价税合计 (优先), 兜底走 extractNumAmount
*/
public static Double extractTotalAmount(String text) {
if (text == null || text.isEmpty()) {
return null;
}
Matcher m = TOTAL_PATTERN.matcher(text);
if (m.find()) {
try {
return Double.parseDouble(m.group(1).replace(",", ""));
} catch (NumberFormatException ignored) {}
}
return extractNumAmount(text);
}
/**
* 抽取税额
*/
public static Double extractTaxAmount(String text) {
if (text == null || text.isEmpty()) {
return null;
}
Matcher m = TAX_AMOUNT_PATTERN.matcher(text);
if (m.find()) {
try {
return Double.parseDouble(m.group(1).replace(",", ""));
} catch (NumberFormatException ignored) {}
}
return null;
}
/**
* 抽取不含税金额
*/
public static Double extractPretaxAmount(String text) {
if (text == null || text.isEmpty()) {
return null;
}
Matcher m = PRETAX_PATTERN.matcher(text);
if (m.find()) {
try {
return Double.parseDouble(m.group(1).replace(",", ""));
} catch (NumberFormatException ignored) {}
}
return null;
}
// ---------- 内部 ----------
/**
* 中文金额归一化: 圆→元, 〇→零, 去空格
*/
private static String normalizeCnAmount(String text) {
if (text == null) return "";
return text.replace("", "").replace("", "").replace(" ", "");
}
/**
* cn2an smart 模式简化实现:
* - 拾佰仟 在元段内累加
* - 万 / 亿 切换大段
* - 角分 处理小数
*/
private static double smartParse(String s) {
// 拆分: 整数部分 (元段) + 小数部分 (角分)
int yuanIdx = s.indexOf('元');
String intPart = yuanIdx >= 0 ? s.substring(0, yuanIdx) : s;
String decPart = yuanIdx >= 0 ? s.substring(yuanIdx + 1) : "";
double intValue = parseIntegerPart(intPart);
double decValue = parseDecimalPart(decPart);
return intValue + decValue;
}
/**
* 解析整数部分: 处理 拾佰仟 万 亿
*/
private static double parseIntegerPart(String s) {
if (s == null || s.isEmpty()) return 0;
// 分段: 以 亿 / 万 分隔
// 例: 叁万玖仟伍佰 → [叁] (亿段=0) + [玖] (万段) + [伍佰] (元段)
// 例: 壹亿贰仟万叁仟 → [壹] (亿段) + [贰] (万段) + [叁] (元段)
double total = 0;
double currentSection = 0; // 当前段(元/万/亿) 的累加值
double currentNum = 0; // 当前数字 (0-9)
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (DIGIT_MAP.containsKey(c)) {
currentNum = DIGIT_MAP.get(c);
} else if (UNIT_MAP.containsKey(c)) {
// 拾佰仟: 处理 拾伍 = 15 (省略壹) 的情况
double v = currentNum == 0 ? 1 : currentNum;
currentSection += v * UNIT_MAP.get(c);
currentNum = 0;
} else if (c == '万') {
currentSection += currentNum;
total += currentSection * 10000;
currentSection = 0;
currentNum = 0;
} else if (c == '亿') {
currentSection += currentNum;
total += currentSection * 100000000;
currentSection = 0;
currentNum = 0;
}
// 零 跳过
}
// 收尾: 段尾若有数字未乘单位 (如 "叁万玖" 末尾的 "玖")
currentSection += currentNum;
total += currentSection;
return total;
}
/**
* 解析小数部分: 角分
*/
private static double parseDecimalPart(String s) {
if (s == null || s.isEmpty()) return 0;
double value = 0;
double currentNum = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (DIGIT_MAP.containsKey(c)) {
currentNum = DIGIT_MAP.get(c);
} else if (BIG_UNIT_MAP.containsKey(c)) {
if (currentNum > 0 || i == 0) {
double v = currentNum == 0 ? 1 : currentNum;
value += v * BIG_UNIT_MAP.get(c);
}
currentNum = 0;
}
}
return value;
}
}
@@ -0,0 +1,44 @@
server:
port: 8802
servlet:
encoding:
charset: UTF-8
force: true
spring:
application:
name: ry-ocr-java
servlet:
multipart:
max-file-size: 30MB
max-request-size: 30MB
jackson:
default-property-inclusion: always
app:
version: "0.1.0"
upload:
max-mb: 20
pdf-dpi: 120 # 优化: 150→120, PDF 渲染像素减 36%, 检测/识别都快, 发票字段精度无影响
ocr:
page-timeout-s: 15
total-timeout-s: 60
qr-full-ocr: true
lang: ch
models-dir: "models"
# 测试用: 配置白名单允许 fapiao.pdf 目录
allowed-dirs:
- "E:\\gitee\\guoju-hegui\\guoju0808\\ry-ocr"
logging:
level:
root: INFO
com.ruoyi.ocr: INFO
pattern:
console: "%clr(%d{HH:mm:ss}){faint} | %clr(%-5p) | %m%n"
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
+41 -10
View File
@@ -75,16 +75,32 @@
<el-table-column prop="bankCard" label="银行卡号码" min-width="160" show-overflow-tooltip />
<el-table-column prop="bankBranch" label="开户行" min-width="130" show-overflow-tooltip />
<el-table-column prop="idCard" label="身份证号" min-width="170" show-overflow-tooltip />
<el-table-column prop="laborForm" label="劳务形式" min-width="80" show-overflow-tooltip />
<el-table-column label="税前劳务费" width="110" align="right">
<el-table-column label="应发金额" width="110" align="right">
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="税金" width="80" align="right">
<el-table-column label="个税税金" width="90" align="right">
<template #default="{ row }">{{ row.tax != null ? '¥ ' + Number(row.tax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="实发劳务费" width="110" align="right">
<el-table-column label="增值税及附加成本" width="130" align="right">
<template #default="{ row }">{{ row.vatAndSurcharge != null ? '¥ ' + Number(row.vatAndSurcharge).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="实发金额" width="110" align="right">
<template #default="{ row }">{{ row.fee != null ? '¥ ' + Number(row.fee).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="120" show-overflow-tooltip />
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }">
<a v-if="row.laborProtocol" :href="row.laborProtocol" target="_blank" class="file-link">查看</a>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="现场照片" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="!row.onSitePhotos" class="text-muted">-</span>
<span v-else>{{ row.onSitePhotos.split(',').length }} </span>
</template>
</el-table-column>
<el-table-column label="签字" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.handsign" type="success" size="small">已签</el-tag>
@@ -345,18 +361,27 @@
<el-form-item label="开户行">
<el-input v-model="attendeeDialog.form.bankBranch" placeholder="如: 北京东城支行" />
</el-form-item>
<el-form-item label="劳务形式">
<el-form-item label="角色">
<el-input v-model="attendeeDialog.form.laborForm" placeholder="如: 授课/主持/评审" />
</el-form-item>
<el-form-item label="税前劳务费">
<el-form-item label="应发金额">
<el-input-number v-model="attendeeDialog.form.feePreTax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="税金">
<el-form-item label="个税税金">
<el-input-number v-model="attendeeDialog.form.tax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="实发劳务费">
<el-form-item label="增值税及附加成本">
<el-input-number v-model="attendeeDialog.form.vatAndSurcharge" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="实发金额">
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="attendeeDialog.form.summary" placeholder="备注/说明" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="现场照片">
<el-input v-model="attendeeDialog.form.onSitePhotos" placeholder="OSS URLs, 多张用逗号分隔" maxlength="1000" />
</el-form-item>
<div v-if="attendeeDialog.editing" class="form-hint">编辑时不能改手机号; 若需改手机号, 请删除后重新添加</div>
</el-form>
<template #footer>
@@ -575,7 +600,10 @@ function emptyAttendeeForm() {
laborForm: '',
feePreTax: 0,
tax: 0,
fee: 0
vatAndSurcharge: 0,
fee: 0,
summary: '',
onSitePhotos: ''
}
}
function resetAttendeeForm() {
@@ -621,7 +649,10 @@ function openAttendeeDialog(row) {
laborForm: row.laborForm || '',
feePreTax: row.feePreTax ?? 0,
tax: row.tax ?? 0,
fee: row.fee ?? 0
vatAndSurcharge: row.vatAndSurcharge ?? 0,
fee: row.fee ?? 0,
summary: row.summary || '',
onSitePhotos: row.onSitePhotos || ''
},
saving: false
}