@@ -99,8 +99,8 @@ brew install opencv
|
|||||||
Download YOLO model files for object detection:
|
Download YOLO model files for object detection:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir models
|
mkdir /mnt/okcomputer/output/models
|
||||||
cd models
|
cd /mnt/okcomputer/output/models
|
||||||
|
|
||||||
# Download YOLOv4 config
|
# Download YOLOv4 config
|
||||||
wget https://raw.githubusercontent.com/AlexeyAB/darknet/master/cfg/yolov4.cfg
|
wget https://raw.githubusercontent.com/AlexeyAB/darknet/master/cfg/yolov4.cfg
|
||||||
|
|||||||
189
k8s/README.md
189
k8s/README.md
@@ -1,189 +0,0 @@
|
|||||||
# Kubernetes Deployment for Auction Monitor
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Build and Push Docker Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build image
|
|
||||||
docker build -t your-registry/auction-monitor:latest .
|
|
||||||
|
|
||||||
# Push to registry
|
|
||||||
docker push your-registry/auction-monitor:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Update deployment.yaml
|
|
||||||
|
|
||||||
Edit `deployment.yaml` and replace:
|
|
||||||
- `image: auction-monitor:latest` with your image
|
|
||||||
- `auction-monitor.yourdomain.com` with your domain
|
|
||||||
|
|
||||||
### 3. Deploy to Kubernetes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Apply all resources
|
|
||||||
kubectl apply -f k8s/deployment.yaml
|
|
||||||
|
|
||||||
# Or apply individually
|
|
||||||
kubectl apply -f k8s/namespace.yaml
|
|
||||||
kubectl apply -f k8s/configmap.yaml
|
|
||||||
kubectl apply -f k8s/secret.yaml
|
|
||||||
kubectl apply -f k8s/deployment.yaml
|
|
||||||
kubectl apply -f k8s/service.yaml
|
|
||||||
kubectl apply -f k8s/ingress.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Verify Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check pods
|
|
||||||
kubectl get pods -n auction-monitor
|
|
||||||
|
|
||||||
# Check services
|
|
||||||
kubectl get svc -n auction-monitor
|
|
||||||
|
|
||||||
# Check ingress
|
|
||||||
kubectl get ingress -n auction-monitor
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
kubectl logs -f deployment/auction-monitor -n auction-monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Access Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Port forward for local access
|
|
||||||
kubectl port-forward svc/auction-monitor 8081:8081 -n auction-monitor
|
|
||||||
|
|
||||||
# Access API
|
|
||||||
curl http://localhost:8081/api/monitor/status
|
|
||||||
|
|
||||||
# Access health check
|
|
||||||
curl http://localhost:8081/health/live
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### ConfigMap
|
|
||||||
|
|
||||||
Edit workflow schedules in `configMap`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
data:
|
|
||||||
AUCTION_WORKFLOW_SCRAPER_IMPORT_CRON: "0 */30 * * * ?" # Every 30 min
|
|
||||||
AUCTION_WORKFLOW_IMAGE_PROCESSING_CRON: "0 0 * * * ?" # Every 1 hour
|
|
||||||
AUCTION_WORKFLOW_BID_MONITORING_CRON: "0 */15 * * * ?" # Every 15 min
|
|
||||||
AUCTION_WORKFLOW_CLOSING_ALERTS_CRON: "0 */5 * * * ?" # Every 5 min
|
|
||||||
```
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
|
|
||||||
Update notification configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create secret
|
|
||||||
kubectl create secret generic auction-secrets \
|
|
||||||
--from-literal=notification-config='smtp:user@gmail.com:password:recipient@example.com' \
|
|
||||||
-n auction-monitor
|
|
||||||
|
|
||||||
# Or edit existing
|
|
||||||
kubectl edit secret auction-secrets -n auction-monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scaling
|
|
||||||
|
|
||||||
### Manual Scaling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Scale to 3 replicas
|
|
||||||
kubectl scale deployment auction-monitor --replicas=3 -n auction-monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auto Scaling
|
|
||||||
|
|
||||||
HPA is configured in `deployment.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spec:
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 3
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
averageUtilization: 80
|
|
||||||
```
|
|
||||||
|
|
||||||
View HPA status:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get hpa -n auction-monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Liveness
|
|
||||||
kubectl exec -it deployment/auction-monitor -n auction-monitor -- \
|
|
||||||
wget -qO- http://localhost:8081/health/live
|
|
||||||
|
|
||||||
# Readiness
|
|
||||||
kubectl exec -it deployment/auction-monitor -n auction-monitor -- \
|
|
||||||
wget -qO- http://localhost:8081/health/ready
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Follow logs
|
|
||||||
kubectl logs -f deployment/auction-monitor -n auction-monitor
|
|
||||||
|
|
||||||
# Logs from all pods
|
|
||||||
kubectl logs -f -l app=auction-monitor -n auction-monitor
|
|
||||||
|
|
||||||
# Previous pod logs
|
|
||||||
kubectl logs deployment/auction-monitor --previous -n auction-monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Pod not starting
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Describe pod
|
|
||||||
kubectl describe pod -l app=auction-monitor -n auction-monitor
|
|
||||||
|
|
||||||
# Check events
|
|
||||||
kubectl get events -n auction-monitor --sort-by='.lastTimestamp'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check PVC
|
|
||||||
kubectl get pvc -n auction-monitor
|
|
||||||
|
|
||||||
# Check volume mount
|
|
||||||
kubectl exec -it deployment/auction-monitor -n auction-monitor -- ls -la /data
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test service
|
|
||||||
kubectl run -it --rm debug --image=busybox --restart=Never -n auction-monitor -- \
|
|
||||||
wget -qO- http://auction-monitor:8081/health/live
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Delete all resources
|
|
||||||
kubectl delete -f k8s/deployment.yaml
|
|
||||||
|
|
||||||
# Or delete namespace (removes everything)
|
|
||||||
kubectl delete namespace auction-monitor
|
|
||||||
```
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: auction-monitor
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: auction-data-pvc
|
|
||||||
namespace: auction-monitor
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 10Gi
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: auction-config
|
|
||||||
namespace: auction-monitor
|
|
||||||
data:
|
|
||||||
AUCTION_DATABASE_PATH: "/data/cache.db"
|
|
||||||
AUCTION_IMAGES_PATH: "/data/images"
|
|
||||||
AUCTION_NOTIFICATION_CONFIG: "desktop"
|
|
||||||
QUARKUS_HTTP_PORT: "8081"
|
|
||||||
QUARKUS_HTTP_HOST: "0.0.0.0"
|
|
||||||
# Workflow schedules (cron expressions)
|
|
||||||
AUCTION_WORKFLOW_SCRAPER_IMPORT_CRON: "0 */30 * * * ?"
|
|
||||||
AUCTION_WORKFLOW_IMAGE_PROCESSING_CRON: "0 0 * * * ?"
|
|
||||||
AUCTION_WORKFLOW_BID_MONITORING_CRON: "0 */15 * * * ?"
|
|
||||||
AUCTION_WORKFLOW_CLOSING_ALERTS_CRON: "0 */5 * * * ?"
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: auction-secrets
|
|
||||||
namespace: auction-monitor
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
# Replace with your actual SMTP configuration
|
|
||||||
notification-config: "desktop"
|
|
||||||
# For email: smtp:your@gmail.com:app_password:recipient@example.com
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: auction-monitor
|
|
||||||
namespace: auction-monitor
|
|
||||||
labels:
|
|
||||||
app: auction-monitor
|
|
||||||
version: v1
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: auction-monitor
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: auction-monitor
|
|
||||||
version: v1
|
|
||||||
annotations:
|
|
||||||
prometheus.io/scrape: "true"
|
|
||||||
prometheus.io/port: "8081"
|
|
||||||
prometheus.io/path: "/q/metrics"
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: auction-monitor
|
|
||||||
image: auction-monitor:latest
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8081
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: JAVA_OPTS
|
|
||||||
value: "-Xmx256m -XX:+UseParallelGC"
|
|
||||||
envFrom:
|
|
||||||
- configMapRef:
|
|
||||||
name: auction-config
|
|
||||||
- secretRef:
|
|
||||||
name: auction-secrets
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /data
|
|
||||||
- name: models
|
|
||||||
mountPath: /app/models
|
|
||||||
readOnly: true
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health/live
|
|
||||||
port: 8081
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 30
|
|
||||||
timeoutSeconds: 3
|
|
||||||
failureThreshold: 3
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health/ready
|
|
||||||
port: 8081
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 3
|
|
||||||
failureThreshold: 3
|
|
||||||
startupProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health/started
|
|
||||||
port: 8081
|
|
||||||
initialDelaySeconds: 0
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 3
|
|
||||||
failureThreshold: 30
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: auction-data-pvc
|
|
||||||
- name: models
|
|
||||||
emptyDir: {} # Or mount from ConfigMap/PVC if you have YOLO models
|
|
||||||
restartPolicy: Always
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: auction-monitor
|
|
||||||
namespace: auction-monitor
|
|
||||||
labels:
|
|
||||||
app: auction-monitor
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
ports:
|
|
||||||
- port: 8081
|
|
||||||
targetPort: 8081
|
|
||||||
protocol: TCP
|
|
||||||
name: http
|
|
||||||
selector:
|
|
||||||
app: auction-monitor
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: auction-monitor-ingress
|
|
||||||
namespace: auction-monitor
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- auction-monitor.yourdomain.com
|
|
||||||
secretName: auction-monitor-tls
|
|
||||||
rules:
|
|
||||||
- host: auction-monitor.yourdomain.com
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: auction-monitor
|
|
||||||
port:
|
|
||||||
number: 8081
|
|
||||||
---
|
|
||||||
apiVersion: autoscaling/v2
|
|
||||||
kind: HorizontalPodAutoscaler
|
|
||||||
metadata:
|
|
||||||
name: auction-monitor-hpa
|
|
||||||
namespace: auction-monitor
|
|
||||||
spec:
|
|
||||||
scaleTargetRef:
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
name: auction-monitor
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 3
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 80
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: memory
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 80
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
person
|
|
||||||
bicycle
|
|
||||||
car
|
|
||||||
motorbike
|
|
||||||
aeroplane
|
|
||||||
bus
|
|
||||||
train
|
|
||||||
truck
|
|
||||||
boat
|
|
||||||
traffic light
|
|
||||||
fire hydrant
|
|
||||||
stop sign
|
|
||||||
parking meter
|
|
||||||
bench
|
|
||||||
bird
|
|
||||||
cat
|
|
||||||
dog
|
|
||||||
horse
|
|
||||||
sheep
|
|
||||||
cow
|
|
||||||
elephant
|
|
||||||
bear
|
|
||||||
zebra
|
|
||||||
giraffe
|
|
||||||
backpack
|
|
||||||
umbrella
|
|
||||||
handbag
|
|
||||||
tie
|
|
||||||
suitcase
|
|
||||||
frisbee
|
|
||||||
skis
|
|
||||||
snowboard
|
|
||||||
sports ball
|
|
||||||
kite
|
|
||||||
baseball bat
|
|
||||||
baseball glove
|
|
||||||
skateboard
|
|
||||||
surfboard
|
|
||||||
tennis racket
|
|
||||||
bottle
|
|
||||||
wine glass
|
|
||||||
cup
|
|
||||||
fork
|
|
||||||
knife
|
|
||||||
spoon
|
|
||||||
bowl
|
|
||||||
banana
|
|
||||||
apple
|
|
||||||
sandwich
|
|
||||||
orange
|
|
||||||
broccoli
|
|
||||||
carrot
|
|
||||||
hot dog
|
|
||||||
pizza
|
|
||||||
donut
|
|
||||||
cake
|
|
||||||
chair
|
|
||||||
sofa
|
|
||||||
pottedplant
|
|
||||||
bed
|
|
||||||
diningtable
|
|
||||||
toilet
|
|
||||||
tvmonitor
|
|
||||||
laptop
|
|
||||||
mouse
|
|
||||||
remote
|
|
||||||
keyboard
|
|
||||||
cell phone
|
|
||||||
microwave
|
|
||||||
oven
|
|
||||||
toaster
|
|
||||||
sink
|
|
||||||
refrigerator
|
|
||||||
book
|
|
||||||
clock
|
|
||||||
vase
|
|
||||||
scissors
|
|
||||||
teddy bear
|
|
||||||
hair drier
|
|
||||||
toothbrush
|
|
||||||
1158
models/yolov4.cfg
1158
models/yolov4.cfg
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
921f4406ecaa54a53031dd695e7bcd96f5dd9be2
|
|
||||||
17
pom.xml
17
pom.xml
@@ -34,6 +34,7 @@
|
|||||||
<maven-compiler-plugin-version>3.14.0</maven-compiler-plugin-version>
|
<maven-compiler-plugin-version>3.14.0</maven-compiler-plugin-version>
|
||||||
<versions-maven-plugin.version>2.19.0</versions-maven-plugin.version>
|
<versions-maven-plugin.version>2.19.0</versions-maven-plugin.version>
|
||||||
<jandex-maven-plugin-version>3.5.0</jandex-maven-plugin-version>
|
<jandex-maven-plugin-version>3.5.0</jandex-maven-plugin-version>
|
||||||
|
<jdbi.version>3.47.0</jdbi.version>
|
||||||
<maven.compiler.args>
|
<maven.compiler.args>
|
||||||
--enable-native-access=ALL-UNNAMED
|
--enable-native-access=ALL-UNNAMED
|
||||||
--add-opens java.base/sun.misc=ALL-UNNAMED
|
--add-opens java.base/sun.misc=ALL-UNNAMED
|
||||||
@@ -161,11 +162,11 @@
|
|||||||
<artifactId>slf4j-api</artifactId>
|
<artifactId>slf4j-api</artifactId>
|
||||||
<version>2.0.9</version>
|
<version>2.0.9</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<!-- <dependency>
|
||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>slf4j-simple</artifactId>
|
<artifactId>slf4j-simple</artifactId>
|
||||||
<version>2.0.9</version>
|
<version>2.0.9</version>
|
||||||
</dependency>
|
</dependency>-->
|
||||||
<!-- JUnit 5 for testing -->
|
<!-- JUnit 5 for testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
@@ -196,6 +197,18 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JDBI3 - Lightweight ORM for SQL -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jdbi</groupId>
|
||||||
|
<artifactId>jdbi3-core</artifactId>
|
||||||
|
<version>${jdbi.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jdbi</groupId>
|
||||||
|
<artifactId>jdbi3-sqlobject</artifactId>
|
||||||
|
<version>${jdbi.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- AssertJ for fluent assertions (optional but recommended) -->
|
<!-- AssertJ for fluent assertions (optional but recommended) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.assertj</groupId>
|
<groupId>org.assertj</groupId>
|
||||||
|
|||||||
3034
scripts/smb-copy.log
Normal file
3034
scripts/smb-copy.log
Normal file
File diff suppressed because it is too large
Load Diff
15
scripts/smb.ps1
Normal file
15
scripts/smb.ps1
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# PowerShell: map the remote share, copy the folder, then clean up
|
||||||
|
$remote = '\\192.168.1.159\shared-auction-data'
|
||||||
|
$local = 'C:\mnt\okcomputer\output\models'
|
||||||
|
|
||||||
|
# (1) create/verify the PSDrive (prompts for password if needed)
|
||||||
|
if (-not (Get-PSDrive -Name Z -ErrorAction SilentlyContinue)) {
|
||||||
|
$cred = Get-Credential -UserName 'tour' -Message 'SMB password for tour@192.168.1.159'
|
||||||
|
New-PSDrive -Name Z -PSProvider FileSystem -Root $remote -Credential $cred -Persist | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# (2) copy the local folder into the share
|
||||||
|
Copy-Item -Path $local -Destination 'Z:\' -Recurse -Force
|
||||||
|
|
||||||
|
# (3) optional cleanup
|
||||||
|
Remove-PSDrive -Name Z -Force
|
||||||
@@ -5,6 +5,7 @@ import jakarta.annotation.PostConstruct;
|
|||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.enterprise.inject.Produces;
|
import jakarta.enterprise.inject.Produces;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
|
import nu.pattern.OpenCV;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.opencv.core.Core;
|
import org.opencv.core.Core;
|
||||||
@@ -24,7 +25,7 @@ public class AuctionMonitorProducer {
|
|||||||
|
|
||||||
@PostConstruct void init() {
|
@PostConstruct void init() {
|
||||||
try {
|
try {
|
||||||
nu.pattern.OpenCV.loadLocally();
|
OpenCV.loadLocally();
|
||||||
LOG.info("✓ OpenCV loaded successfully");
|
LOG.info("✓ OpenCV loaded successfully");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage());
|
LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage());
|
||||||
|
|||||||
@@ -1,686 +1,137 @@
|
|||||||
package auctiora;
|
package auctiora;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import auctiora.db.*;
|
||||||
import jakarta.inject.Inject;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.jdbi.v3.core.Jdbi;
|
||||||
|
|
||||||
import java.io.Console;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for persisting auctions, lots, and images into a SQLite database.
|
* Refactored database service using repository pattern and JDBI3.
|
||||||
* Data is typically populated by an external scraper process;
|
* Delegates operations to specialized repositories for better separation of concerns.
|
||||||
* this service enriches it with image processing and monitoring.
|
*
|
||||||
|
* @deprecated Legacy methods maintained for backward compatibility.
|
||||||
|
* New code should use repositories directly via dependency injection.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class DatabaseService {
|
public class DatabaseService {
|
||||||
|
|
||||||
private final String url;
|
private final Jdbi jdbi;
|
||||||
|
private final LotRepository lotRepository;
|
||||||
|
private final AuctionRepository auctionRepository;
|
||||||
|
private final ImageRepository imageRepository;
|
||||||
|
|
||||||
DatabaseService(String dbPath) {
|
/**
|
||||||
// Enable WAL mode and busy timeout for concurrent access
|
* Constructor for programmatic instantiation (tests, CLI tools).
|
||||||
this.url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000";
|
*/
|
||||||
|
public DatabaseService(String dbPath) {
|
||||||
|
String url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000";
|
||||||
|
this.jdbi = Jdbi.create(url);
|
||||||
|
|
||||||
|
// Initialize schema
|
||||||
|
DatabaseSchema.ensureSchema(jdbi);
|
||||||
|
|
||||||
|
// Create repositories
|
||||||
|
this.lotRepository = new LotRepository(jdbi);
|
||||||
|
this.auctionRepository = new AuctionRepository(jdbi);
|
||||||
|
this.imageRepository = new ImageRepository(jdbi);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates tables if they do not already exist.
|
* Constructor with JDBI instance (for dependency injection).
|
||||||
* Schema supports data from external scraper and adds image processing results.
|
|
||||||
*/
|
*/
|
||||||
void ensureSchema() throws SQLException {
|
public DatabaseService(Jdbi jdbi) {
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
this.jdbi = jdbi;
|
||||||
// Enable WAL mode for better concurrent access
|
DatabaseSchema.ensureSchema(jdbi);
|
||||||
stmt.execute("PRAGMA journal_mode=WAL");
|
|
||||||
stmt.execute("PRAGMA busy_timeout=10000");
|
|
||||||
stmt.execute("PRAGMA synchronous=NORMAL");
|
|
||||||
|
|
||||||
// Cache table (for HTTP caching)
|
this.lotRepository = new LotRepository(jdbi);
|
||||||
stmt.execute("""
|
this.auctionRepository = new AuctionRepository(jdbi);
|
||||||
CREATE TABLE IF NOT EXISTS cache (
|
this.imageRepository = new ImageRepository(jdbi);
|
||||||
url TEXT PRIMARY KEY,
|
|
||||||
content BLOB,
|
|
||||||
timestamp REAL,
|
|
||||||
status_code INTEGER
|
|
||||||
)""");
|
|
||||||
|
|
||||||
// Auctions table (populated by external scraper)
|
|
||||||
stmt.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS auctions (
|
|
||||||
auction_id TEXT PRIMARY KEY,
|
|
||||||
url TEXT UNIQUE,
|
|
||||||
title TEXT,
|
|
||||||
location TEXT,
|
|
||||||
lots_count INTEGER,
|
|
||||||
first_lot_closing_time TEXT,
|
|
||||||
scraped_at TEXT,
|
|
||||||
city TEXT,
|
|
||||||
country TEXT,
|
|
||||||
type TEXT,
|
|
||||||
lot_count INTEGER DEFAULT 0,
|
|
||||||
closing_time TEXT,
|
|
||||||
discovered_at INTEGER
|
|
||||||
)""");
|
|
||||||
|
|
||||||
// Lots table (populated by external scraper)
|
|
||||||
stmt.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS lots (
|
|
||||||
lot_id TEXT PRIMARY KEY,
|
|
||||||
auction_id TEXT,
|
|
||||||
url TEXT UNIQUE,
|
|
||||||
title TEXT,
|
|
||||||
current_bid TEXT,
|
|
||||||
bid_count INTEGER,
|
|
||||||
closing_time TEXT,
|
|
||||||
viewing_time TEXT,
|
|
||||||
pickup_date TEXT,
|
|
||||||
location TEXT,
|
|
||||||
description TEXT,
|
|
||||||
category TEXT,
|
|
||||||
scraped_at TEXT,
|
|
||||||
sale_id INTEGER,
|
|
||||||
manufacturer TEXT,
|
|
||||||
type TEXT,
|
|
||||||
year INTEGER,
|
|
||||||
currency TEXT DEFAULT 'EUR',
|
|
||||||
closing_notified INTEGER DEFAULT 0,
|
|
||||||
starting_bid TEXT,
|
|
||||||
minimum_bid TEXT,
|
|
||||||
status TEXT,
|
|
||||||
brand TEXT,
|
|
||||||
model TEXT,
|
|
||||||
attributes_json TEXT,
|
|
||||||
first_bid_time TEXT,
|
|
||||||
last_bid_time TEXT,
|
|
||||||
bid_velocity REAL,
|
|
||||||
bid_increment REAL,
|
|
||||||
year_manufactured INTEGER,
|
|
||||||
condition_score REAL,
|
|
||||||
condition_description TEXT,
|
|
||||||
serial_number TEXT,
|
|
||||||
damage_description TEXT,
|
|
||||||
followers_count INTEGER DEFAULT 0,
|
|
||||||
estimated_min_price REAL,
|
|
||||||
estimated_max_price REAL,
|
|
||||||
lot_condition TEXT,
|
|
||||||
appearance TEXT,
|
|
||||||
estimated_min REAL,
|
|
||||||
estimated_max REAL,
|
|
||||||
next_bid_step_cents INTEGER,
|
|
||||||
condition TEXT,
|
|
||||||
category_path TEXT,
|
|
||||||
city_location TEXT,
|
|
||||||
country_code TEXT,
|
|
||||||
bidding_status TEXT,
|
|
||||||
packaging TEXT,
|
|
||||||
quantity INTEGER,
|
|
||||||
vat REAL,
|
|
||||||
buyer_premium_percentage REAL,
|
|
||||||
remarks TEXT,
|
|
||||||
reserve_price REAL,
|
|
||||||
reserve_met INTEGER,
|
|
||||||
view_count INTEGER,
|
|
||||||
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id)
|
|
||||||
)""");
|
|
||||||
|
|
||||||
// Images table (populated by external scraper with URLs and local_path)
|
|
||||||
stmt.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS images (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
lot_id TEXT,
|
|
||||||
url TEXT,
|
|
||||||
local_path TEXT,
|
|
||||||
downloaded INTEGER DEFAULT 0,
|
|
||||||
labels TEXT,
|
|
||||||
processed_at INTEGER,
|
|
||||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
|
||||||
)""");
|
|
||||||
|
|
||||||
// Bid history table
|
|
||||||
stmt.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS bid_history (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
lot_id TEXT NOT NULL,
|
|
||||||
bid_amount REAL NOT NULL,
|
|
||||||
bid_time TEXT NOT NULL,
|
|
||||||
is_autobid INTEGER DEFAULT 0,
|
|
||||||
bidder_id TEXT,
|
|
||||||
bidder_number INTEGER,
|
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
|
||||||
)""");
|
|
||||||
|
|
||||||
// Indexes for performance
|
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON cache(timestamp)");
|
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country)");
|
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_lots_sale_id ON lots(sale_id)");
|
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_images_lot_id ON images(lot_id)");
|
|
||||||
stmt.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_lot_url ON images(lot_id, url)");
|
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_lot_time ON bid_history(lot_id, bid_time)");
|
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_bidder ON bid_history(bidder_id)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ==================== LEGACY COMPATIBILITY METHODS ====================
|
||||||
* Inserts or updates an auction record (typically called by external scraper)
|
// These methods delegate to repositories for backward compatibility
|
||||||
* Handles both auction_id conflicts and url uniqueness constraints
|
|
||||||
*/
|
|
||||||
synchronized void upsertAuction(AuctionInfo auction) throws SQLException {
|
|
||||||
// First try to INSERT with ON CONFLICT on auction_id
|
|
||||||
var insertSql = """
|
|
||||||
INSERT INTO auctions (auction_id, title, location, city, country, url, type, lot_count, closing_time, discovered_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(auction_id) DO UPDATE SET
|
|
||||||
title = excluded.title,
|
|
||||||
location = excluded.location,
|
|
||||||
city = excluded.city,
|
|
||||||
country = excluded.country,
|
|
||||||
url = excluded.url,
|
|
||||||
type = excluded.type,
|
|
||||||
lot_count = excluded.lot_count,
|
|
||||||
closing_time = excluded.closing_time
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url)) {
|
void ensureSchema() {
|
||||||
try (var ps = conn.prepareStatement(insertSql)) {
|
DatabaseSchema.ensureSchema(jdbi);
|
||||||
ps.setLong(1, auction.auctionId());
|
|
||||||
ps.setString(2, auction.title());
|
|
||||||
ps.setString(3, auction.location());
|
|
||||||
ps.setString(4, auction.city());
|
|
||||||
ps.setString(5, auction.country());
|
|
||||||
ps.setString(6, auction.url());
|
|
||||||
ps.setString(7, auction.typePrefix());
|
|
||||||
ps.setInt(8, auction.lotCount());
|
|
||||||
ps.setString(9, auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null);
|
|
||||||
ps.setLong(10, Instant.now().getEpochSecond());
|
|
||||||
ps.executeUpdate();
|
|
||||||
} catch (SQLException e) {
|
|
||||||
// Handle both PRIMARY KEY and URL constraint failures
|
|
||||||
String errMsg = e.getMessage();
|
|
||||||
if (errMsg.contains("UNIQUE constraint failed: auctions.auction_id") ||
|
|
||||||
errMsg.contains("UNIQUE constraint failed: auctions.url") ||
|
|
||||||
errMsg.contains("PRIMARY KEY constraint failed")) {
|
|
||||||
|
|
||||||
// Try updating by URL as fallback (most reliable unique identifier)
|
|
||||||
var updateByUrlSql = """
|
|
||||||
UPDATE auctions SET
|
|
||||||
auction_id = ?,
|
|
||||||
title = ?,
|
|
||||||
location = ?,
|
|
||||||
city = ?,
|
|
||||||
country = ?,
|
|
||||||
type = ?,
|
|
||||||
lot_count = ?,
|
|
||||||
closing_time = ?
|
|
||||||
WHERE url = ?
|
|
||||||
""";
|
|
||||||
try (var ps = conn.prepareStatement(updateByUrlSql)) {
|
|
||||||
ps.setLong(1, auction.auctionId());
|
|
||||||
ps.setString(2, auction.title());
|
|
||||||
ps.setString(3, auction.location());
|
|
||||||
ps.setString(4, auction.city());
|
|
||||||
ps.setString(5, auction.country());
|
|
||||||
ps.setString(6, auction.typePrefix());
|
|
||||||
ps.setInt(7, auction.lotCount());
|
|
||||||
ps.setString(8, auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null);
|
|
||||||
ps.setString(9, auction.url());
|
|
||||||
|
|
||||||
int updated = ps.executeUpdate();
|
|
||||||
if (updated == 0) {
|
|
||||||
// Auction doesn't exist by URL either - this is unexpected
|
|
||||||
log.warn("Could not insert or update auction with url={}, auction_id={} - constraint violation but no existing record found",
|
|
||||||
auction.url(), auction.auctionId());
|
|
||||||
} else {
|
|
||||||
log.debug("Updated existing auction by URL: {}", auction.url());
|
|
||||||
}
|
|
||||||
} catch (SQLException updateEx) {
|
|
||||||
// UPDATE also failed - log and swallow the exception
|
|
||||||
log.warn("Failed to update auction by URL ({}): {}", auction.url(), updateEx.getMessage());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized void upsertAuction(AuctionInfo auction) {
|
||||||
* Retrieves all auctions from the database
|
auctionRepository.upsert(auction);
|
||||||
*/
|
|
||||||
synchronized List<AuctionInfo> getAllAuctions() throws SQLException {
|
|
||||||
List<AuctionInfo> auctions = new ArrayList<>();
|
|
||||||
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time FROM auctions";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
|
||||||
var rs = stmt.executeQuery(sql);
|
|
||||||
while (rs.next()) {
|
|
||||||
var closingStr = rs.getString("closing_time");
|
|
||||||
LocalDateTime closing = null;
|
|
||||||
if (closingStr != null && !closingStr.isBlank()) {
|
|
||||||
try {
|
|
||||||
closing = LocalDateTime.parse(closingStr);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auctions.add(new AuctionInfo(
|
synchronized List<AuctionInfo> getAllAuctions() {
|
||||||
rs.getLong("auction_id"),
|
return auctionRepository.getAll();
|
||||||
rs.getString("title"),
|
|
||||||
rs.getString("location"),
|
|
||||||
rs.getString("city"),
|
|
||||||
rs.getString("country"),
|
|
||||||
rs.getString("url"),
|
|
||||||
rs.getString("type"),
|
|
||||||
rs.getInt("lot_count"),
|
|
||||||
closing
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return auctions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized List<AuctionInfo> getAuctionsByCountry(String countryCode) {
|
||||||
* Retrieves auctions by country code
|
return auctionRepository.getByCountry(countryCode);
|
||||||
*/
|
|
||||||
synchronized List<AuctionInfo> getAuctionsByCountry(String countryCode) throws SQLException {
|
|
||||||
List<AuctionInfo> auctions = new ArrayList<>();
|
|
||||||
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time "
|
|
||||||
+ "FROM auctions WHERE country = ?";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, countryCode);
|
|
||||||
var rs = ps.executeQuery();
|
|
||||||
while (rs.next()) {
|
|
||||||
var closingStr = rs.getString("closing_time");
|
|
||||||
LocalDateTime closing = null;
|
|
||||||
if (closingStr != null && !closingStr.isBlank()) {
|
|
||||||
try {
|
|
||||||
closing = LocalDateTime.parse(closingStr);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auctions.add(new AuctionInfo(
|
synchronized void upsertLot(Lot lot) {
|
||||||
rs.getLong("auction_id"),
|
lotRepository.upsert(lot);
|
||||||
rs.getString("title"),
|
|
||||||
rs.getString("location"),
|
|
||||||
rs.getString("city"),
|
|
||||||
rs.getString("country"),
|
|
||||||
rs.getString("url"),
|
|
||||||
rs.getString("type"),
|
|
||||||
rs.getInt("lot_count"),
|
|
||||||
closing
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return auctions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized void upsertLotWithIntelligence(Lot lot) {
|
||||||
* Inserts or updates a lot record (typically called by external scraper)
|
lotRepository.upsertWithIntelligence(lot);
|
||||||
*/
|
|
||||||
synchronized void upsertLot(Lot lot) throws SQLException {
|
|
||||||
// First try to update existing lot by lot_id
|
|
||||||
var updateSql = """
|
|
||||||
UPDATE lots SET
|
|
||||||
sale_id = ?,
|
|
||||||
title = ?,
|
|
||||||
description = ?,
|
|
||||||
manufacturer = ?,
|
|
||||||
type = ?,
|
|
||||||
year = ?,
|
|
||||||
category = ?,
|
|
||||||
current_bid = ?,
|
|
||||||
currency = ?,
|
|
||||||
url = ?,
|
|
||||||
closing_time = ?
|
|
||||||
WHERE lot_id = ?
|
|
||||||
""";
|
|
||||||
|
|
||||||
var insertSql = """
|
|
||||||
INSERT OR IGNORE INTO lots (lot_id, sale_id, title, description, manufacturer, type, year, category, current_bid, currency, url, closing_time, closing_notified)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url)) {
|
|
||||||
// Try UPDATE first
|
|
||||||
try (var ps = conn.prepareStatement(updateSql)) {
|
|
||||||
ps.setString(1, String.valueOf(lot.saleId()));
|
|
||||||
ps.setString(2, lot.title());
|
|
||||||
ps.setString(3, lot.description());
|
|
||||||
ps.setString(4, lot.manufacturer());
|
|
||||||
ps.setString(5, lot.type());
|
|
||||||
ps.setInt(6, lot.year());
|
|
||||||
ps.setString(7, lot.category());
|
|
||||||
ps.setDouble(8, lot.currentBid());
|
|
||||||
ps.setString(9, lot.currency());
|
|
||||||
ps.setString(10, lot.url());
|
|
||||||
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
|
|
||||||
ps.setString(12, String.valueOf(lot.lotId()));
|
|
||||||
|
|
||||||
int updated = ps.executeUpdate();
|
|
||||||
if (updated > 0) {
|
|
||||||
return; // Successfully updated existing record
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no rows updated, try INSERT (ignore if conflicts with UNIQUE constraints)
|
synchronized void updateLotCurrentBid(Lot lot) {
|
||||||
try (var ps = conn.prepareStatement(insertSql)) {
|
lotRepository.updateCurrentBid(lot);
|
||||||
ps.setString(1, String.valueOf(lot.lotId()));
|
|
||||||
ps.setString(2, String.valueOf(lot.saleId()));
|
|
||||||
ps.setString(3, lot.title());
|
|
||||||
ps.setString(4, lot.description());
|
|
||||||
ps.setString(5, lot.manufacturer());
|
|
||||||
ps.setString(6, lot.type());
|
|
||||||
ps.setInt(7, lot.year());
|
|
||||||
ps.setString(8, lot.category());
|
|
||||||
ps.setDouble(9, lot.currentBid());
|
|
||||||
ps.setString(10, lot.currency());
|
|
||||||
ps.setString(11, lot.url());
|
|
||||||
ps.setString(12, lot.closingTime() != null ? lot.closingTime().toString() : null);
|
|
||||||
ps.setInt(13, lot.closingNotified() ? 1 : 0);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized void updateLotNotificationFlags(Lot lot) {
|
||||||
* Updates a lot with full intelligence data from GraphQL enrichment.
|
lotRepository.updateNotificationFlags(lot);
|
||||||
* This is a comprehensive update that includes all 24 intelligence fields.
|
|
||||||
*/
|
|
||||||
synchronized void upsertLotWithIntelligence(Lot lot) throws SQLException {
|
|
||||||
var sql = """
|
|
||||||
UPDATE lots SET
|
|
||||||
sale_id = ?,
|
|
||||||
title = ?,
|
|
||||||
description = ?,
|
|
||||||
manufacturer = ?,
|
|
||||||
type = ?,
|
|
||||||
year = ?,
|
|
||||||
category = ?,
|
|
||||||
current_bid = ?,
|
|
||||||
currency = ?,
|
|
||||||
url = ?,
|
|
||||||
closing_time = ?,
|
|
||||||
followers_count = ?,
|
|
||||||
estimated_min = ?,
|
|
||||||
estimated_max = ?,
|
|
||||||
next_bid_step_in_cents = ?,
|
|
||||||
condition = ?,
|
|
||||||
category_path = ?,
|
|
||||||
city_location = ?,
|
|
||||||
country_code = ?,
|
|
||||||
bidding_status = ?,
|
|
||||||
appearance = ?,
|
|
||||||
packaging = ?,
|
|
||||||
quantity = ?,
|
|
||||||
vat = ?,
|
|
||||||
buyer_premium_percentage = ?,
|
|
||||||
remarks = ?,
|
|
||||||
starting_bid = ?,
|
|
||||||
reserve_price = ?,
|
|
||||||
reserve_met = ?,
|
|
||||||
bid_increment = ?,
|
|
||||||
view_count = ?,
|
|
||||||
first_bid_time = ?,
|
|
||||||
last_bid_time = ?,
|
|
||||||
bid_velocity = ?
|
|
||||||
WHERE lot_id = ?
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
|
|
||||||
ps.setLong(1, lot.saleId());
|
|
||||||
ps.setString(2, lot.title());
|
|
||||||
ps.setString(3, lot.description());
|
|
||||||
ps.setString(4, lot.manufacturer());
|
|
||||||
ps.setString(5, lot.type());
|
|
||||||
ps.setInt(6, lot.year());
|
|
||||||
ps.setString(7, lot.category());
|
|
||||||
ps.setDouble(8, lot.currentBid());
|
|
||||||
ps.setString(9, lot.currency());
|
|
||||||
ps.setString(10, lot.url());
|
|
||||||
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
|
|
||||||
|
|
||||||
// Intelligence fields
|
|
||||||
if (lot.followersCount() != null) ps.setInt(12, lot.followersCount());
|
|
||||||
else ps.setNull(12, java.sql.Types.INTEGER);
|
|
||||||
if (lot.estimatedMin() != null) ps.setDouble(13, lot.estimatedMin());
|
|
||||||
else ps.setNull(13, java.sql.Types.REAL);
|
|
||||||
if (lot.estimatedMax() != null) ps.setDouble(14, lot.estimatedMax());
|
|
||||||
else ps.setNull(14, java.sql.Types.REAL);
|
|
||||||
if (lot.nextBidStepInCents() != null) ps.setLong(15, lot.nextBidStepInCents());
|
|
||||||
else ps.setNull(15, java.sql.Types.BIGINT);
|
|
||||||
ps.setString(16, lot.condition());
|
|
||||||
ps.setString(17, lot.categoryPath());
|
|
||||||
ps.setString(18, lot.cityLocation());
|
|
||||||
ps.setString(19, lot.countryCode());
|
|
||||||
ps.setString(20, lot.biddingStatus());
|
|
||||||
ps.setString(21, lot.appearance());
|
|
||||||
ps.setString(22, lot.packaging());
|
|
||||||
if (lot.quantity() != null) ps.setLong(23, lot.quantity());
|
|
||||||
else ps.setNull(23, java.sql.Types.BIGINT);
|
|
||||||
if (lot.vat() != null) ps.setDouble(24, lot.vat());
|
|
||||||
else ps.setNull(24, java.sql.Types.REAL);
|
|
||||||
if (lot.buyerPremiumPercentage() != null) ps.setDouble(25, lot.buyerPremiumPercentage());
|
|
||||||
else ps.setNull(25, java.sql.Types.REAL);
|
|
||||||
ps.setString(26, lot.remarks());
|
|
||||||
if (lot.startingBid() != null) ps.setDouble(27, lot.startingBid());
|
|
||||||
else ps.setNull(27, java.sql.Types.REAL);
|
|
||||||
if (lot.reservePrice() != null) ps.setDouble(28, lot.reservePrice());
|
|
||||||
else ps.setNull(28, java.sql.Types.REAL);
|
|
||||||
if (lot.reserveMet() != null) ps.setInt(29, lot.reserveMet() ? 1 : 0);
|
|
||||||
else ps.setNull(29, java.sql.Types.INTEGER);
|
|
||||||
if (lot.bidIncrement() != null) ps.setDouble(30, lot.bidIncrement());
|
|
||||||
else ps.setNull(30, java.sql.Types.REAL);
|
|
||||||
if (lot.viewCount() != null) ps.setInt(31, lot.viewCount());
|
|
||||||
else ps.setNull(31, java.sql.Types.INTEGER);
|
|
||||||
ps.setString(32, lot.firstBidTime() != null ? lot.firstBidTime().toString() : null);
|
|
||||||
ps.setString(33, lot.lastBidTime() != null ? lot.lastBidTime().toString() : null);
|
|
||||||
if (lot.bidVelocity() != null) ps.setDouble(34, lot.bidVelocity());
|
|
||||||
else ps.setNull(34, java.sql.Types.REAL);
|
|
||||||
|
|
||||||
ps.setLong(35, lot.lotId());
|
|
||||||
|
|
||||||
int updated = ps.executeUpdate();
|
|
||||||
if (updated == 0) {
|
|
||||||
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized List<Lot> getActiveLots() {
|
||||||
* Inserts a complete image record (for testing/legacy compatibility).
|
return lotRepository.getActiveLots();
|
||||||
* In production, scraper inserts with local_path, monitor updates labels via updateImageLabels.
|
|
||||||
*/
|
|
||||||
synchronized void insertImage(long lotId, String url, String filePath, List<String> labels) throws SQLException {
|
|
||||||
var sql = "INSERT INTO images (lot_id, url, local_path, labels, processed_at, downloaded) VALUES (?, ?, ?, ?, ?, 1)";
|
|
||||||
try (var conn = DriverManager.getConnection(this.url); var ps = conn.prepareStatement(sql)) {
|
|
||||||
ps.setLong(1, lotId);
|
|
||||||
ps.setString(2, url);
|
|
||||||
ps.setString(3, filePath);
|
|
||||||
ps.setString(4, String.join(",", labels));
|
|
||||||
ps.setLong(5, Instant.now().getEpochSecond());
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized List<Lot> getAllLots() {
|
||||||
* Updates the labels field for an image after object detection
|
return lotRepository.getAllLots();
|
||||||
*/
|
|
||||||
synchronized void updateImageLabels(int imageId, List<String> labels) throws SQLException {
|
|
||||||
var sql = "UPDATE images SET labels = ?, processed_at = ? WHERE id = ?";
|
|
||||||
try (var conn = DriverManager.getConnection(this.url); var ps = conn.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, String.join(",", labels));
|
|
||||||
ps.setLong(2, Instant.now().getEpochSecond());
|
|
||||||
ps.setInt(3, imageId);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized List<BidHistory> getBidHistory(String lotId) {
|
||||||
* Gets the labels for a specific image
|
return lotRepository.getBidHistory(lotId);
|
||||||
*/
|
|
||||||
synchronized List<String> getImageLabels(int imageId) throws SQLException {
|
|
||||||
var sql = "SELECT labels FROM images WHERE id = ?";
|
|
||||||
try (var conn = DriverManager.getConnection(this.url); var ps = conn.prepareStatement(sql)) {
|
|
||||||
ps.setInt(1, imageId);
|
|
||||||
var rs = ps.executeQuery();
|
|
||||||
if (rs.next()) {
|
|
||||||
var labelsStr = rs.getString("labels");
|
|
||||||
if (labelsStr != null && !labelsStr.isEmpty()) {
|
|
||||||
return List.of(labelsStr.split(","));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return List.of();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized void insertBidHistory(List<BidHistory> bidHistory) {
|
||||||
* Retrieves images for a specific lot
|
lotRepository.insertBidHistory(bidHistory);
|
||||||
*/
|
|
||||||
synchronized List<ImageRecord> getImagesForLot(long lotId) throws SQLException {
|
|
||||||
List<ImageRecord> images = new ArrayList<>();
|
|
||||||
var sql = "SELECT id, lot_id, url, local_path, labels FROM images WHERE lot_id = ?";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
|
|
||||||
ps.setLong(1, lotId);
|
|
||||||
var rs = ps.executeQuery();
|
|
||||||
while (rs.next()) {
|
|
||||||
images.add(new ImageRecord(
|
|
||||||
rs.getInt("id"),
|
|
||||||
rs.getLong("lot_id"),
|
|
||||||
rs.getString("url"),
|
|
||||||
rs.getString("local_path"),
|
|
||||||
rs.getString("labels")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return images;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized void insertImage(long lotId, String url, String filePath, List<String> labels) {
|
||||||
* Retrieves all lots that are active and need monitoring
|
imageRepository.insert(lotId, url, filePath, labels);
|
||||||
*/
|
|
||||||
synchronized List<Lot> getActiveLots() throws SQLException {
|
|
||||||
List<Lot> list = new ArrayList<>();
|
|
||||||
var sql = "SELECT lot_id, sale_id as auction_id, title, description, manufacturer, type, year, category, " +
|
|
||||||
"current_bid, currency, url, closing_time, closing_notified FROM lots";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
|
||||||
var rs = stmt.executeQuery(sql);
|
|
||||||
while (rs.next()) {
|
|
||||||
try {
|
|
||||||
// Use ScraperDataAdapter to handle TEXT parsing from legacy database
|
|
||||||
var lot = ScraperDataAdapter.fromScraperLot(rs);
|
|
||||||
list.add(lot);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to parse lot {}: {}", rs.getString("lot_id"), e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized void updateImageLabels(int imageId, List<String> labels) {
|
||||||
* Retrieves all lots from the database
|
imageRepository.updateLabels(imageId, labels);
|
||||||
*/
|
|
||||||
synchronized List<Lot> getAllLots() throws SQLException {
|
|
||||||
return getActiveLots();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized List<String> getImageLabels(int imageId) {
|
||||||
* Gets the total number of images in the database
|
return imageRepository.getLabels(imageId);
|
||||||
*/
|
|
||||||
synchronized int getImageCount() throws SQLException {
|
|
||||||
var sql = "SELECT COUNT(*) as count FROM images";
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
|
||||||
var rs = stmt.executeQuery(sql);
|
|
||||||
if (rs.next()) {
|
|
||||||
return rs.getInt("count");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized List<ImageRecord> getImagesForLot(long lotId) {
|
||||||
* Updates the current bid of a lot (used by monitoring service)
|
return imageRepository.getImagesForLot(lotId)
|
||||||
*/
|
.stream()
|
||||||
synchronized void updateLotCurrentBid(Lot lot) throws SQLException {
|
.map(img -> new ImageRecord(img.id(), img.lotId(), img.url(), img.filePath(), img.labels()))
|
||||||
try (var conn = DriverManager.getConnection(url);
|
.toList();
|
||||||
var ps = conn.prepareStatement("UPDATE lots SET current_bid = ? WHERE lot_id = ?")) {
|
|
||||||
ps.setDouble(1, lot.currentBid());
|
|
||||||
ps.setString(2, String.valueOf(lot.lotId()));
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized List<ImageDetectionRecord> getImagesNeedingDetection() {
|
||||||
* Updates the closingNotified flag of a lot
|
return imageRepository.getImagesNeedingDetection()
|
||||||
*/
|
.stream()
|
||||||
synchronized void updateLotNotificationFlags(Lot lot) throws SQLException {
|
.map(img -> new ImageDetectionRecord(img.id(), img.lotId(), img.filePath()))
|
||||||
try (var conn = DriverManager.getConnection(url);
|
.toList();
|
||||||
var ps = conn.prepareStatement("UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) {
|
|
||||||
ps.setInt(1, lot.closingNotified() ? 1 : 0);
|
|
||||||
ps.setString(2, String.valueOf(lot.lotId()));
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
synchronized int getImageCount() {
|
||||||
* Retrieves bid history for a specific lot
|
return imageRepository.getImageCount();
|
||||||
*/
|
|
||||||
synchronized List<BidHistory> getBidHistory(String lotId) throws SQLException {
|
|
||||||
List<BidHistory> history = new ArrayList<>();
|
|
||||||
var sql = "SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number " +
|
|
||||||
"FROM bid_history WHERE lot_id = ? ORDER BY bid_time DESC LIMIT 100";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url);
|
|
||||||
var ps = conn.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, lotId);
|
|
||||||
var rs = ps.executeQuery();
|
|
||||||
|
|
||||||
while (rs.next()) {
|
|
||||||
LocalDateTime bidTime = null;
|
|
||||||
var bidTimeStr = rs.getString("bid_time");
|
|
||||||
if (bidTimeStr != null && !bidTimeStr.isBlank()) {
|
|
||||||
try {
|
|
||||||
bidTime = LocalDateTime.parse(bidTimeStr);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("Invalid bid_time format: {}", bidTimeStr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
history.add(new BidHistory(
|
synchronized List<AuctionInfo> importAuctionsFromScraper() {
|
||||||
rs.getInt("id"),
|
return jdbi.withHandle(handle -> {
|
||||||
rs.getString("lot_id"),
|
|
||||||
rs.getDouble("bid_amount"),
|
|
||||||
bidTime,
|
|
||||||
rs.getInt("is_autobid") != 0,
|
|
||||||
rs.getString("bidder_id"),
|
|
||||||
rs.getInt("bidder_number")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return history;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports auctions from scraper's schema format.
|
|
||||||
* Since the scraper doesn't populate a separate auctions table,
|
|
||||||
* we derive auction metadata by aggregating lots data.
|
|
||||||
*
|
|
||||||
* @return List of imported auctions
|
|
||||||
*/
|
|
||||||
synchronized List<AuctionInfo> importAuctionsFromScraper() throws SQLException {
|
|
||||||
List<AuctionInfo> imported = new ArrayList<>();
|
|
||||||
|
|
||||||
// Derive auctions from lots table (scraper doesn't populate auctions table)
|
|
||||||
var sql = """
|
var sql = """
|
||||||
SELECT
|
SELECT
|
||||||
l.auction_id,
|
l.auction_id,
|
||||||
@@ -695,107 +146,73 @@ public class DatabaseService {
|
|||||||
GROUP BY l.auction_id
|
GROUP BY l.auction_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
return handle.createQuery(sql)
|
||||||
var rs = stmt.executeQuery(sql);
|
.map((rs, ctx) -> {
|
||||||
while (rs.next()) {
|
|
||||||
try {
|
try {
|
||||||
var auction = ScraperDataAdapter.fromScraperAuction(rs);
|
var auction = ScraperDataAdapter.fromScraperAuction(rs);
|
||||||
// Skip auctions with invalid IDs (0 indicates parsing failed)
|
if (auction.auctionId() != 0L) {
|
||||||
if (auction.auctionId() == 0L) {
|
auctionRepository.upsert(auction);
|
||||||
log.debug("Skipping auction with invalid ID: auction_id={}", auction.auctionId());
|
return auction;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
upsertAuction(auction);
|
|
||||||
imported.add(auction);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
// SQLException should be handled by upsertAuction, but if it propagates here, log it
|
|
||||||
log.warn("Failed to import auction (SQL error): {}", e.getMessage());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Other exceptions (parsing errors, etc)
|
log.warn("Failed to import auction: {}", e.getMessage());
|
||||||
log.warn("Failed to import auction (parsing error): {}", e.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
} catch (SQLException e) {
|
})
|
||||||
// Table might not exist in scraper format - that's ok
|
.list()
|
||||||
log.info("ℹ️ Scraper lots table not found or incompatible schema: {}", e.getMessage());
|
.stream()
|
||||||
|
.filter(a -> a != null)
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return imported;
|
synchronized List<Lot> importLotsFromScraper() {
|
||||||
}
|
return jdbi.withHandle(handle -> {
|
||||||
|
var sql = "SELECT * FROM lots";
|
||||||
|
|
||||||
/**
|
return handle.createQuery(sql)
|
||||||
* Imports lots from scraper's schema format.
|
.map((rs, ctx) -> {
|
||||||
* Reads from scraper's tables and converts to monitor format using adapter.
|
|
||||||
*
|
|
||||||
* @return List of imported lots
|
|
||||||
*/
|
|
||||||
synchronized List<Lot> importLotsFromScraper() throws SQLException {
|
|
||||||
List<Lot> imported = new ArrayList<>();
|
|
||||||
var sql = "SELECT lot_id, auction_id, title, description, category, " +
|
|
||||||
"current_bid, closing_time, url " +
|
|
||||||
"FROM lots";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
|
||||||
var rs = stmt.executeQuery(sql);
|
|
||||||
while (rs.next()) {
|
|
||||||
try {
|
try {
|
||||||
var lot = ScraperDataAdapter.fromScraperLot(rs);
|
var lot = ScraperDataAdapter.fromScraperLot(rs);
|
||||||
// Skip lots with invalid IDs (0 indicates parsing failed)
|
if (lot.lotId() != 0L && lot.saleId() != 0L) {
|
||||||
if (lot.lotId() == 0L || lot.saleId() == 0L) {
|
lotRepository.upsert(lot);
|
||||||
log.debug("Skipping lot with invalid ID: lot_id={}, sale_id={}", lot.lotId(), lot.saleId());
|
return lot;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
upsertLot(lot);
|
|
||||||
imported.add(lot);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("Failed to import lot: " + e.getMessage());
|
log.warn("Failed to import lot: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
} catch (SQLException e) {
|
})
|
||||||
// Table might not exist in scraper format - that's ok
|
.list()
|
||||||
log.info("ℹ️ Scraper lots table not found or incompatible schema");
|
.stream()
|
||||||
|
.filter(l -> l != null)
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return imported;
|
// ==================== DIRECT REPOSITORY ACCESS ====================
|
||||||
|
// Expose repositories for modern usage patterns
|
||||||
|
|
||||||
|
public LotRepository lots() {
|
||||||
|
return lotRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public AuctionRepository auctions() {
|
||||||
* Gets images that have been downloaded by the scraper but need object detection.
|
return auctionRepository;
|
||||||
* Only returns images that have local_path set but no labels yet.
|
|
||||||
*
|
|
||||||
* @return List of images needing object detection
|
|
||||||
*/
|
|
||||||
synchronized List<ImageDetectionRecord> getImagesNeedingDetection() throws SQLException {
|
|
||||||
List<ImageDetectionRecord> images = new ArrayList<>();
|
|
||||||
var sql = """
|
|
||||||
SELECT i.id, i.lot_id, i.local_path
|
|
||||||
FROM images i
|
|
||||||
WHERE i.local_path IS NOT NULL
|
|
||||||
AND i.local_path != ''
|
|
||||||
AND (i.labels IS NULL OR i.labels = '')
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
|
||||||
var rs = stmt.executeQuery(sql);
|
|
||||||
while (rs.next()) {
|
|
||||||
// Extract numeric lot ID from TEXT field (e.g., "A1-34732-49" -> 3473249)
|
|
||||||
String lotIdStr = rs.getString("lot_id");
|
|
||||||
long lotId = ScraperDataAdapter.extractNumericId(lotIdStr);
|
|
||||||
|
|
||||||
images.add(new ImageDetectionRecord(
|
|
||||||
rs.getInt("id"),
|
|
||||||
lotId,
|
|
||||||
rs.getString("local_path")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
log.info("ℹ️ No images needing detection found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return images;
|
public ImageRepository images() {
|
||||||
|
return imageRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
record ImageRecord(int id, long lotId, String url, String filePath, String labels) { }
|
public Jdbi getJdbi() {
|
||||||
|
return jdbi;
|
||||||
record ImageDetectionRecord(int id, long lotId, String filePath) { }
|
}
|
||||||
|
|
||||||
|
// ==================== LEGACY RECORDS ====================
|
||||||
|
// Keep records for backward compatibility with existing code
|
||||||
|
|
||||||
|
public record ImageRecord(int id, long lotId, String url, String filePath, String labels) {}
|
||||||
|
|
||||||
|
public record ImageDetectionRecord(int id, long lotId, String filePath) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import java.time.LocalDateTime;
|
|||||||
/// Data typically populated by the external scraper process.
|
/// Data typically populated by the external scraper process.
|
||||||
/// This project enriches the data with image analysis and monitoring.
|
/// This project enriches the data with image analysis and monitoring.
|
||||||
@With
|
@With
|
||||||
record Lot(
|
public record Lot(
|
||||||
long saleId,
|
long saleId,
|
||||||
long lotId,
|
long lotId,
|
||||||
String displayId, // Full lot ID string (e.g., "A1-34732-49") for GraphQL queries
|
String displayId, // Full lot ID string (e.g., "A1-34732-49") for GraphQL queries
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public class TroostwijkMonitor {
|
|||||||
for (var lot : activeLots) {
|
for (var lot : activeLots) {
|
||||||
checkAndUpdateLot(lot);
|
checkAndUpdateLot(lot);
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
log.error("Error during scheduled monitoring", e);
|
log.error("Error during scheduled monitoring", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,11 +74,7 @@ public class TroostwijkMonitor {
|
|||||||
notifier.sendNotification(
|
notifier.sendNotification(
|
||||||
"Kavel " + lot.lotId() + " sluit binnen " + minutesLeft + " min.",
|
"Kavel " + lot.lotId() + " sluit binnen " + minutesLeft + " min.",
|
||||||
"Lot nearing closure", 1);
|
"Lot nearing closure", 1);
|
||||||
try {
|
|
||||||
db.updateLotNotificationFlags(lot.withClosingNotified(true));
|
db.updateLotNotificationFlags(lot.withClosingNotified(true));
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES);
|
scheduler.schedule(() -> refreshLotBid(lot), 5, TimeUnit.MINUTES);
|
||||||
}
|
}
|
||||||
@@ -109,7 +105,7 @@ public class TroostwijkMonitor {
|
|||||||
notifier.sendNotification(msg, "Kavel bieding update", 0);
|
notifier.sendNotification(msg, "Kavel bieding update", 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException | InterruptedException | SQLException e) {
|
} catch (IOException | InterruptedException e) {
|
||||||
log.warn("Failed to refresh bid for lot {}", lot.lotId(), e);
|
log.warn("Failed to refresh bid for lot {}", lot.lotId(), e);
|
||||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
@@ -125,7 +121,7 @@ public class TroostwijkMonitor {
|
|||||||
var sum = allLots.stream().mapToDouble(Lot::currentBid).sum();
|
var sum = allLots.stream().mapToDouble(Lot::currentBid).sum();
|
||||||
log.info("Total current bids: €{:.2f}", sum);
|
log.info("Total current bids: €{:.2f}", sum);
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
log.warn("Could not retrieve database stats", e);
|
log.warn("Could not retrieve database stats", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
src/main/java/auctiora/db/AuctionRepository.java
Normal file
164
src/main/java/auctiora/db/AuctionRepository.java
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package auctiora.db;
|
||||||
|
|
||||||
|
import auctiora.AuctionInfo;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jdbi.v3.core.Jdbi;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for auction-related database operations using JDBI3.
|
||||||
|
* Handles CRUD operations and queries for auctions.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuctionRepository {
|
||||||
|
|
||||||
|
private final Jdbi jdbi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates an auction record.
|
||||||
|
* Handles both auction_id conflicts and url uniqueness constraints.
|
||||||
|
*/
|
||||||
|
public void upsert(AuctionInfo auction) {
|
||||||
|
jdbi.useTransaction(handle -> {
|
||||||
|
try {
|
||||||
|
// Try INSERT with ON CONFLICT on auction_id
|
||||||
|
handle.createUpdate("""
|
||||||
|
INSERT INTO auctions (
|
||||||
|
auction_id, title, location, city, country, url, type, lot_count, closing_time, discovered_at
|
||||||
|
) VALUES (
|
||||||
|
:auctionId, :title, :location, :city, :country, :url, :type, :lotCount, :closingTime, :discoveredAt
|
||||||
|
)
|
||||||
|
ON CONFLICT(auction_id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
location = excluded.location,
|
||||||
|
city = excluded.city,
|
||||||
|
country = excluded.country,
|
||||||
|
url = excluded.url,
|
||||||
|
type = excluded.type,
|
||||||
|
lot_count = excluded.lot_count,
|
||||||
|
closing_time = excluded.closing_time
|
||||||
|
""")
|
||||||
|
.bind("auctionId", auction.auctionId())
|
||||||
|
.bind("title", auction.title())
|
||||||
|
.bind("location", auction.location())
|
||||||
|
.bind("city", auction.city())
|
||||||
|
.bind("country", auction.country())
|
||||||
|
.bind("url", auction.url())
|
||||||
|
.bind("type", auction.typePrefix())
|
||||||
|
.bind("lotCount", auction.lotCount())
|
||||||
|
.bind("closingTime", auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null)
|
||||||
|
.bind("discoveredAt", java.time.Instant.now().getEpochSecond())
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// If UNIQUE constraint on url fails, try updating by url
|
||||||
|
String errMsg = e.getMessage();
|
||||||
|
if (errMsg != null && (errMsg.contains("UNIQUE constraint failed") ||
|
||||||
|
errMsg.contains("PRIMARY KEY constraint failed"))) {
|
||||||
|
log.debug("Auction conflict detected, attempting update by URL: {}", auction.url());
|
||||||
|
|
||||||
|
int updated = handle.createUpdate("""
|
||||||
|
UPDATE auctions SET
|
||||||
|
auction_id = :auctionId,
|
||||||
|
title = :title,
|
||||||
|
location = :location,
|
||||||
|
city = :city,
|
||||||
|
country = :country,
|
||||||
|
type = :type,
|
||||||
|
lot_count = :lotCount,
|
||||||
|
closing_time = :closingTime
|
||||||
|
WHERE url = :url
|
||||||
|
""")
|
||||||
|
.bind("auctionId", auction.auctionId())
|
||||||
|
.bind("title", auction.title())
|
||||||
|
.bind("location", auction.location())
|
||||||
|
.bind("city", auction.city())
|
||||||
|
.bind("country", auction.country())
|
||||||
|
.bind("type", auction.typePrefix())
|
||||||
|
.bind("lotCount", auction.lotCount())
|
||||||
|
.bind("closingTime", auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null)
|
||||||
|
.bind("url", auction.url())
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (updated == 0) {
|
||||||
|
log.warn("Failed to update auction by URL: {}", auction.url());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("Unexpected error upserting auction: {}", e.getMessage(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all auctions from the database.
|
||||||
|
*/
|
||||||
|
public List<AuctionInfo> getAll() {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("SELECT * FROM auctions")
|
||||||
|
.map((rs, ctx) -> {
|
||||||
|
String closingStr = rs.getString("closing_time");
|
||||||
|
LocalDateTime closingTime = null;
|
||||||
|
if (closingStr != null && !closingStr.isBlank()) {
|
||||||
|
try {
|
||||||
|
closingTime = LocalDateTime.parse(closingStr);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Invalid closing_time format: {}", closingStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuctionInfo(
|
||||||
|
rs.getLong("auction_id"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("location"),
|
||||||
|
rs.getString("city"),
|
||||||
|
rs.getString("country"),
|
||||||
|
rs.getString("url"),
|
||||||
|
rs.getString("type"),
|
||||||
|
rs.getInt("lot_count"),
|
||||||
|
closingTime
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.list()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves auctions filtered by country code.
|
||||||
|
*/
|
||||||
|
public List<AuctionInfo> getByCountry(String countryCode) {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("SELECT * FROM auctions WHERE country = :country")
|
||||||
|
.bind("country", countryCode)
|
||||||
|
.map((rs, ctx) -> {
|
||||||
|
String closingStr = rs.getString("closing_time");
|
||||||
|
LocalDateTime closingTime = null;
|
||||||
|
if (closingStr != null && !closingStr.isBlank()) {
|
||||||
|
try {
|
||||||
|
closingTime = LocalDateTime.parse(closingStr);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Invalid closing_time format: {}", closingStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuctionInfo(
|
||||||
|
rs.getLong("auction_id"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("location"),
|
||||||
|
rs.getString("city"),
|
||||||
|
rs.getString("country"),
|
||||||
|
rs.getString("url"),
|
||||||
|
rs.getString("type"),
|
||||||
|
rs.getInt("lot_count"),
|
||||||
|
closingTime
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.list()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/main/java/auctiora/db/DatabaseSchema.java
Normal file
154
src/main/java/auctiora/db/DatabaseSchema.java
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package auctiora.db;
|
||||||
|
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
import org.jdbi.v3.core.Jdbi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database schema DDL definitions for all tables and indexes.
|
||||||
|
* Uses text blocks (Java 15+) for clean SQL formatting.
|
||||||
|
*/
|
||||||
|
@UtilityClass
|
||||||
|
public class DatabaseSchema {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes all database tables and indexes if they don't exist.
|
||||||
|
*/
|
||||||
|
public void ensureSchema(Jdbi jdbi) {
|
||||||
|
jdbi.useHandle(handle -> {
|
||||||
|
// Enable WAL mode for better concurrent access
|
||||||
|
handle.execute("PRAGMA journal_mode=WAL");
|
||||||
|
handle.execute("PRAGMA busy_timeout=10000");
|
||||||
|
handle.execute("PRAGMA synchronous=NORMAL");
|
||||||
|
|
||||||
|
createTables(handle);
|
||||||
|
createIndexes(handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createTables(org.jdbi.v3.core.Handle handle) {
|
||||||
|
// Cache table (for HTTP caching)
|
||||||
|
handle.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS cache (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
content BLOB,
|
||||||
|
timestamp REAL,
|
||||||
|
status_code INTEGER
|
||||||
|
)""");
|
||||||
|
|
||||||
|
// Auctions table (populated by external scraper)
|
||||||
|
handle.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS auctions (
|
||||||
|
auction_id TEXT PRIMARY KEY,
|
||||||
|
url TEXT UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
location TEXT,
|
||||||
|
lots_count INTEGER,
|
||||||
|
first_lot_closing_time TEXT,
|
||||||
|
scraped_at TEXT,
|
||||||
|
city TEXT,
|
||||||
|
country TEXT,
|
||||||
|
type TEXT,
|
||||||
|
lot_count INTEGER DEFAULT 0,
|
||||||
|
closing_time TEXT,
|
||||||
|
discovered_at INTEGER
|
||||||
|
)""");
|
||||||
|
|
||||||
|
// Lots table (populated by external scraper)
|
||||||
|
handle.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS lots (
|
||||||
|
lot_id TEXT PRIMARY KEY,
|
||||||
|
auction_id TEXT,
|
||||||
|
url TEXT UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
current_bid TEXT,
|
||||||
|
bid_count INTEGER,
|
||||||
|
closing_time TEXT,
|
||||||
|
viewing_time TEXT,
|
||||||
|
pickup_date TEXT,
|
||||||
|
location TEXT,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
scraped_at TEXT,
|
||||||
|
sale_id INTEGER,
|
||||||
|
manufacturer TEXT,
|
||||||
|
type TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
currency TEXT DEFAULT 'EUR',
|
||||||
|
closing_notified INTEGER DEFAULT 0,
|
||||||
|
starting_bid TEXT,
|
||||||
|
minimum_bid TEXT,
|
||||||
|
status TEXT,
|
||||||
|
brand TEXT,
|
||||||
|
model TEXT,
|
||||||
|
attributes_json TEXT,
|
||||||
|
first_bid_time TEXT,
|
||||||
|
last_bid_time TEXT,
|
||||||
|
bid_velocity REAL,
|
||||||
|
bid_increment REAL,
|
||||||
|
year_manufactured INTEGER,
|
||||||
|
condition_score REAL,
|
||||||
|
condition_description TEXT,
|
||||||
|
serial_number TEXT,
|
||||||
|
damage_description TEXT,
|
||||||
|
followers_count INTEGER DEFAULT 0,
|
||||||
|
estimated_min_price REAL,
|
||||||
|
estimated_max_price REAL,
|
||||||
|
lot_condition TEXT,
|
||||||
|
appearance TEXT,
|
||||||
|
estimated_min REAL,
|
||||||
|
estimated_max REAL,
|
||||||
|
next_bid_step_cents INTEGER,
|
||||||
|
condition TEXT,
|
||||||
|
category_path TEXT,
|
||||||
|
city_location TEXT,
|
||||||
|
country_code TEXT,
|
||||||
|
bidding_status TEXT,
|
||||||
|
packaging TEXT,
|
||||||
|
quantity INTEGER,
|
||||||
|
vat REAL,
|
||||||
|
buyer_premium_percentage REAL,
|
||||||
|
remarks TEXT,
|
||||||
|
reserve_price REAL,
|
||||||
|
reserve_met INTEGER,
|
||||||
|
view_count INTEGER,
|
||||||
|
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id)
|
||||||
|
)""");
|
||||||
|
|
||||||
|
// Images table (populated by external scraper with URLs and local_path)
|
||||||
|
handle.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lot_id TEXT,
|
||||||
|
url TEXT,
|
||||||
|
local_path TEXT,
|
||||||
|
downloaded INTEGER DEFAULT 0,
|
||||||
|
labels TEXT,
|
||||||
|
processed_at INTEGER,
|
||||||
|
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
||||||
|
)""");
|
||||||
|
|
||||||
|
// Bid history table
|
||||||
|
handle.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS bid_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lot_id TEXT NOT NULL,
|
||||||
|
bid_amount REAL NOT NULL,
|
||||||
|
bid_time TEXT NOT NULL,
|
||||||
|
is_autobid INTEGER DEFAULT 0,
|
||||||
|
bidder_id TEXT,
|
||||||
|
bidder_number INTEGER,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
||||||
|
)""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createIndexes(org.jdbi.v3.core.Handle handle) {
|
||||||
|
handle.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON cache(timestamp)");
|
||||||
|
handle.execute("CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country)");
|
||||||
|
handle.execute("CREATE INDEX IF NOT EXISTS idx_lots_sale_id ON lots(sale_id)");
|
||||||
|
handle.execute("CREATE INDEX IF NOT EXISTS idx_images_lot_id ON images(lot_id)");
|
||||||
|
handle.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_lot_url ON images(lot_id, url)");
|
||||||
|
handle.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_lot_time ON bid_history(lot_id, bid_time)");
|
||||||
|
handle.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_bidder ON bid_history(bidder_id)");
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/main/java/auctiora/db/ImageRepository.java
Normal file
137
src/main/java/auctiora/db/ImageRepository.java
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package auctiora.db;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jdbi.v3.core.Jdbi;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for image-related database operations using JDBI3.
|
||||||
|
* Handles image storage, object detection labels, and processing status.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ImageRepository {
|
||||||
|
|
||||||
|
private final Jdbi jdbi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image record containing all image metadata.
|
||||||
|
*/
|
||||||
|
public record ImageRecord(int id, long lotId, String url, String filePath, String labels) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal record for images needing object detection processing.
|
||||||
|
*/
|
||||||
|
public record ImageDetectionRecord(int id, long lotId, String filePath) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a complete image record (for testing/legacy compatibility).
|
||||||
|
* In production, scraper inserts with local_path, monitor updates labels via updateLabels.
|
||||||
|
*/
|
||||||
|
public void insert(long lotId, String url, String filePath, List<String> labels) {
|
||||||
|
jdbi.useHandle(handle ->
|
||||||
|
handle.createUpdate("""
|
||||||
|
INSERT INTO images (lot_id, url, local_path, labels, processed_at, downloaded)
|
||||||
|
VALUES (:lotId, :url, :localPath, :labels, :processedAt, 1)
|
||||||
|
""")
|
||||||
|
.bind("lotId", lotId)
|
||||||
|
.bind("url", url)
|
||||||
|
.bind("localPath", filePath)
|
||||||
|
.bind("labels", String.join(",", labels))
|
||||||
|
.bind("processedAt", Instant.now().getEpochSecond())
|
||||||
|
.execute()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the labels field for an image after object detection.
|
||||||
|
*/
|
||||||
|
public void updateLabels(int imageId, List<String> labels) {
|
||||||
|
jdbi.useHandle(handle ->
|
||||||
|
handle.createUpdate("UPDATE images SET labels = :labels, processed_at = :processedAt WHERE id = :id")
|
||||||
|
.bind("labels", String.join(",", labels))
|
||||||
|
.bind("processedAt", Instant.now().getEpochSecond())
|
||||||
|
.bind("id", imageId)
|
||||||
|
.execute()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the labels for a specific image.
|
||||||
|
*/
|
||||||
|
public List<String> getLabels(int imageId) {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("SELECT labels FROM images WHERE id = :id")
|
||||||
|
.bind("id", imageId)
|
||||||
|
.mapTo(String.class)
|
||||||
|
.findOne()
|
||||||
|
.map(labelsStr -> {
|
||||||
|
if (labelsStr != null && !labelsStr.isEmpty()) {
|
||||||
|
return List.of(labelsStr.split(","));
|
||||||
|
}
|
||||||
|
return List.<String>of();
|
||||||
|
})
|
||||||
|
.orElse(List.of())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves images for a specific lot.
|
||||||
|
*/
|
||||||
|
public List<ImageRecord> getImagesForLot(long lotId) {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("SELECT id, lot_id, url, local_path, labels FROM images WHERE lot_id = :lotId")
|
||||||
|
.bind("lotId", lotId)
|
||||||
|
.map((rs, ctx) -> new ImageRecord(
|
||||||
|
rs.getInt("id"),
|
||||||
|
rs.getLong("lot_id"),
|
||||||
|
rs.getString("url"),
|
||||||
|
rs.getString("local_path"),
|
||||||
|
rs.getString("labels")
|
||||||
|
))
|
||||||
|
.list()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets images that have been downloaded by the scraper but need object detection.
|
||||||
|
* Only returns images that have local_path set but no labels yet.
|
||||||
|
*/
|
||||||
|
public List<ImageDetectionRecord> getImagesNeedingDetection() {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("""
|
||||||
|
SELECT i.id, i.lot_id, i.local_path
|
||||||
|
FROM images i
|
||||||
|
WHERE i.local_path IS NOT NULL
|
||||||
|
AND i.local_path != ''
|
||||||
|
AND (i.labels IS NULL OR i.labels = '')
|
||||||
|
""")
|
||||||
|
.map((rs, ctx) -> {
|
||||||
|
// Extract numeric lot ID from TEXT field (e.g., "A1-34732-49" -> 3473249)
|
||||||
|
String lotIdStr = rs.getString("lot_id");
|
||||||
|
long lotId = auctiora.ScraperDataAdapter.extractNumericId(lotIdStr);
|
||||||
|
|
||||||
|
return new ImageDetectionRecord(
|
||||||
|
rs.getInt("id"),
|
||||||
|
lotId,
|
||||||
|
rs.getString("local_path")
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.list()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total number of images in the database.
|
||||||
|
*/
|
||||||
|
public int getImageCount() {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("SELECT COUNT(*) FROM images")
|
||||||
|
.mapTo(Integer.class)
|
||||||
|
.one()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
275
src/main/java/auctiora/db/LotRepository.java
Normal file
275
src/main/java/auctiora/db/LotRepository.java
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package auctiora.db;
|
||||||
|
|
||||||
|
import auctiora.Lot;
|
||||||
|
import auctiora.BidHistory;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jdbi.v3.core.Jdbi;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static java.sql.Types.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for lot-related database operations using JDBI3.
|
||||||
|
* Handles CRUD operations and queries for auction lots.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LotRepository {
|
||||||
|
|
||||||
|
private final Jdbi jdbi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts or updates a lot (upsert operation).
|
||||||
|
* First tries UPDATE, then falls back to INSERT if lot doesn't exist.
|
||||||
|
*/
|
||||||
|
public void upsert(Lot lot) {
|
||||||
|
jdbi.useTransaction(handle -> {
|
||||||
|
// Try UPDATE first
|
||||||
|
int updated = handle.createUpdate("""
|
||||||
|
UPDATE lots SET
|
||||||
|
sale_id = :saleId,
|
||||||
|
auction_id = :auctionId,
|
||||||
|
title = :title,
|
||||||
|
description = :description,
|
||||||
|
manufacturer = :manufacturer,
|
||||||
|
type = :type,
|
||||||
|
year = :year,
|
||||||
|
category = :category,
|
||||||
|
current_bid = :currentBid,
|
||||||
|
currency = :currency,
|
||||||
|
url = :url,
|
||||||
|
closing_time = :closingTime
|
||||||
|
WHERE lot_id = :lotId
|
||||||
|
""")
|
||||||
|
.bind("saleId", String.valueOf(lot.saleId()))
|
||||||
|
.bind("auctionId", String.valueOf(lot.saleId())) // auction_id = sale_id
|
||||||
|
.bind("title", lot.title())
|
||||||
|
.bind("description", lot.description())
|
||||||
|
.bind("manufacturer", lot.manufacturer())
|
||||||
|
.bind("type", lot.type())
|
||||||
|
.bind("year", lot.year())
|
||||||
|
.bind("category", lot.category())
|
||||||
|
.bind("currentBid", lot.currentBid())
|
||||||
|
.bind("currency", lot.currency())
|
||||||
|
.bind("url", lot.url())
|
||||||
|
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
|
||||||
|
.bind("lotId", String.valueOf(lot.lotId()))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (updated == 0) {
|
||||||
|
// No rows updated, perform INSERT
|
||||||
|
handle.createUpdate("""
|
||||||
|
INSERT OR IGNORE INTO lots (
|
||||||
|
lot_id, sale_id, auction_id, title, description, manufacturer, type, year,
|
||||||
|
category, current_bid, currency, url, closing_time, closing_notified
|
||||||
|
) VALUES (
|
||||||
|
:lotId, :saleId, :auctionId, :title, :description, :manufacturer, :type, :year,
|
||||||
|
:category, :currentBid, :currency, :url, :closingTime, :closingNotified
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
.bind("lotId", String.valueOf(lot.lotId()))
|
||||||
|
.bind("saleId", String.valueOf(lot.saleId()))
|
||||||
|
.bind("auctionId", String.valueOf(lot.saleId())) // auction_id = sale_id
|
||||||
|
.bind("title", lot.title())
|
||||||
|
.bind("description", lot.description())
|
||||||
|
.bind("manufacturer", lot.manufacturer())
|
||||||
|
.bind("type", lot.type())
|
||||||
|
.bind("year", lot.year())
|
||||||
|
.bind("category", lot.category())
|
||||||
|
.bind("currentBid", lot.currentBid())
|
||||||
|
.bind("currency", lot.currency())
|
||||||
|
.bind("url", lot.url())
|
||||||
|
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
|
||||||
|
.bind("closingNotified", lot.closingNotified() ? 1 : 0)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a lot with full intelligence data from GraphQL enrichment.
|
||||||
|
* Includes all 24+ intelligence fields from bidding platform.
|
||||||
|
*/
|
||||||
|
public void upsertWithIntelligence(Lot lot) {
|
||||||
|
jdbi.useHandle(handle -> {
|
||||||
|
var update = handle.createUpdate("""
|
||||||
|
UPDATE lots SET
|
||||||
|
sale_id = :saleId,
|
||||||
|
title = :title,
|
||||||
|
description = :description,
|
||||||
|
manufacturer = :manufacturer,
|
||||||
|
type = :type,
|
||||||
|
year = :year,
|
||||||
|
category = :category,
|
||||||
|
current_bid = :currentBid,
|
||||||
|
currency = :currency,
|
||||||
|
url = :url,
|
||||||
|
closing_time = :closingTime,
|
||||||
|
followers_count = :followersCount,
|
||||||
|
estimated_min = :estimatedMin,
|
||||||
|
estimated_max = :estimatedMax,
|
||||||
|
next_bid_step_cents = :nextBidStepInCents,
|
||||||
|
condition = :condition,
|
||||||
|
category_path = :categoryPath,
|
||||||
|
city_location = :cityLocation,
|
||||||
|
country_code = :countryCode,
|
||||||
|
bidding_status = :biddingStatus,
|
||||||
|
appearance = :appearance,
|
||||||
|
packaging = :packaging,
|
||||||
|
quantity = :quantity,
|
||||||
|
vat = :vat,
|
||||||
|
buyer_premium_percentage = :buyerPremiumPercentage,
|
||||||
|
remarks = :remarks,
|
||||||
|
starting_bid = :startingBid,
|
||||||
|
reserve_price = :reservePrice,
|
||||||
|
reserve_met = :reserveMet,
|
||||||
|
bid_increment = :bidIncrement,
|
||||||
|
view_count = :viewCount,
|
||||||
|
first_bid_time = :firstBidTime,
|
||||||
|
last_bid_time = :lastBidTime,
|
||||||
|
bid_velocity = :bidVelocity
|
||||||
|
WHERE lot_id = :lotId
|
||||||
|
""")
|
||||||
|
.bind("saleId", lot.saleId())
|
||||||
|
.bind("title", lot.title())
|
||||||
|
.bind("description", lot.description())
|
||||||
|
.bind("manufacturer", lot.manufacturer())
|
||||||
|
.bind("type", lot.type())
|
||||||
|
.bind("year", lot.year())
|
||||||
|
.bind("category", lot.category())
|
||||||
|
.bind("currentBid", lot.currentBid())
|
||||||
|
.bind("currency", lot.currency())
|
||||||
|
.bind("url", lot.url())
|
||||||
|
.bind("closingTime", lot.closingTime() != null ? lot.closingTime().toString() : null)
|
||||||
|
.bind("followersCount", lot.followersCount())
|
||||||
|
.bind("estimatedMin", lot.estimatedMin())
|
||||||
|
.bind("estimatedMax", lot.estimatedMax())
|
||||||
|
.bind("nextBidStepInCents", lot.nextBidStepInCents())
|
||||||
|
.bind("condition", lot.condition())
|
||||||
|
.bind("categoryPath", lot.categoryPath())
|
||||||
|
.bind("cityLocation", lot.cityLocation())
|
||||||
|
.bind("countryCode", lot.countryCode())
|
||||||
|
.bind("biddingStatus", lot.biddingStatus())
|
||||||
|
.bind("appearance", lot.appearance())
|
||||||
|
.bind("packaging", lot.packaging())
|
||||||
|
.bind("quantity", lot.quantity())
|
||||||
|
.bind("vat", lot.vat())
|
||||||
|
.bind("buyerPremiumPercentage", lot.buyerPremiumPercentage())
|
||||||
|
.bind("remarks", lot.remarks())
|
||||||
|
.bind("startingBid", lot.startingBid())
|
||||||
|
.bind("reservePrice", lot.reservePrice())
|
||||||
|
.bind("reserveMet", lot.reserveMet() != null && lot.reserveMet() ? 1 : null)
|
||||||
|
.bind("bidIncrement", lot.bidIncrement())
|
||||||
|
.bind("viewCount", lot.viewCount())
|
||||||
|
.bind("firstBidTime", lot.firstBidTime() != null ? lot.firstBidTime().toString() : null)
|
||||||
|
.bind("lastBidTime", lot.lastBidTime() != null ? lot.lastBidTime().toString() : null)
|
||||||
|
.bind("bidVelocity", lot.bidVelocity())
|
||||||
|
.bind("lotId", lot.lotId());
|
||||||
|
|
||||||
|
int updated = update.execute();
|
||||||
|
if (updated == 0) {
|
||||||
|
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates only the current bid for a lot (lightweight update).
|
||||||
|
*/
|
||||||
|
public void updateCurrentBid(Lot lot) {
|
||||||
|
jdbi.useHandle(handle ->
|
||||||
|
handle.createUpdate("UPDATE lots SET current_bid = :bid WHERE lot_id = :lotId")
|
||||||
|
.bind("bid", lot.currentBid())
|
||||||
|
.bind("lotId", String.valueOf(lot.lotId()))
|
||||||
|
.execute()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates notification flags for a lot.
|
||||||
|
*/
|
||||||
|
public void updateNotificationFlags(Lot lot) {
|
||||||
|
jdbi.useHandle(handle ->
|
||||||
|
handle.createUpdate("UPDATE lots SET closing_notified = :notified WHERE lot_id = :lotId")
|
||||||
|
.bind("notified", lot.closingNotified() ? 1 : 0)
|
||||||
|
.bind("lotId", String.valueOf(lot.lotId()))
|
||||||
|
.execute()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all active lots.
|
||||||
|
* Note: Despite the name, this returns ALL lots (legacy behavior for backward compatibility).
|
||||||
|
*/
|
||||||
|
public List<Lot> getActiveLots() {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("SELECT * FROM lots")
|
||||||
|
.map((rs, ctx) -> auctiora.ScraperDataAdapter.fromScraperLot(rs))
|
||||||
|
.list()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all lots from the database.
|
||||||
|
*/
|
||||||
|
public List<Lot> getAllLots() {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("SELECT * FROM lots")
|
||||||
|
.map((rs, ctx) -> auctiora.ScraperDataAdapter.fromScraperLot(rs))
|
||||||
|
.list()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves bid history for a specific lot.
|
||||||
|
*/
|
||||||
|
public List<BidHistory> getBidHistory(String lotId) {
|
||||||
|
return jdbi.withHandle(handle ->
|
||||||
|
handle.createQuery("""
|
||||||
|
SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number
|
||||||
|
FROM bid_history
|
||||||
|
WHERE lot_id = :lotId
|
||||||
|
ORDER BY bid_time DESC
|
||||||
|
""")
|
||||||
|
.bind("lotId", lotId)
|
||||||
|
.map((rs, ctx) -> new BidHistory(
|
||||||
|
rs.getInt("id"),
|
||||||
|
rs.getString("lot_id"),
|
||||||
|
rs.getDouble("bid_amount"),
|
||||||
|
LocalDateTime.parse(rs.getString("bid_time")),
|
||||||
|
rs.getInt("is_autobid") != 0,
|
||||||
|
rs.getString("bidder_id"),
|
||||||
|
(Integer) rs.getObject("bidder_number")
|
||||||
|
))
|
||||||
|
.list()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts bid history records in batch.
|
||||||
|
*/
|
||||||
|
public void insertBidHistory(List<BidHistory> bidHistory) {
|
||||||
|
jdbi.useHandle(handle -> {
|
||||||
|
var batch = handle.prepareBatch("""
|
||||||
|
INSERT OR IGNORE INTO bid_history (
|
||||||
|
lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number
|
||||||
|
) VALUES (:lotId, :bidAmount, :bidTime, :isAutobid, :bidderId, :bidderNumber)
|
||||||
|
""");
|
||||||
|
|
||||||
|
bidHistory.forEach(bid ->
|
||||||
|
batch.bind("lotId", bid.lotId())
|
||||||
|
.bind("bidAmount", bid.bidAmount())
|
||||||
|
.bind("bidTime", bid.bidTime().toString())
|
||||||
|
.bind("isAutobid", bid.isAutobid() ? 1 : 0)
|
||||||
|
.bind("bidderId", bid.bidderId())
|
||||||
|
.bind("bidderNumber", bid.bidderNumber())
|
||||||
|
.add()
|
||||||
|
);
|
||||||
|
|
||||||
|
batch.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,12 +44,12 @@ quarkus.rest.path=/
|
|||||||
quarkus.http.root-path=/
|
quarkus.http.root-path=/
|
||||||
|
|
||||||
# Auction Monitor Configuration
|
# Auction Monitor Configuration
|
||||||
auction.database.path=C:\\mnt\\okcomputer\\output\\cache.db
|
auction.database.path=/mnt/okcomputer/output/cache.db
|
||||||
auction.images.path=C:\\mnt\\okcomputer\\output\\images
|
auction.images.path=/mnt/okcomputer/output/images
|
||||||
auction.notification.config=desktop
|
auction.notification.config=desktop
|
||||||
auction.yolo.config=models/yolov4.cfg
|
auction.yolo.config=/mnt/okcomputer/output/models/yolov4.cfg
|
||||||
auction.yolo.weights=models/yolov4.weights
|
auction.yolo.weights=/mnt/okcomputer/output/models/yolov4.weights
|
||||||
auction.yolo.classes=models/coco.names
|
auction.yolo.classes=/mnt/okcomputer/output/models/coco.names
|
||||||
|
|
||||||
# Scheduler Configuration
|
# Scheduler Configuration
|
||||||
quarkus.scheduler.enabled=true
|
quarkus.scheduler.enabled=true
|
||||||
|
|||||||
138
src/test/java/auctiora/ClosingTimeCalculationTest.java
Normal file
138
src/test/java/auctiora/ClosingTimeCalculationTest.java
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package auctiora;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for closing time calculations that power the UI
|
||||||
|
* Tests the minutesUntilClose() logic used in dashboard and alerts
|
||||||
|
*/
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
@DisplayName("Closing Time Calculation Tests")
|
||||||
|
class ClosingTimeCalculationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
@DisplayName("Should calculate minutes until close for lot closing in 15 minutes")
|
||||||
|
void testMinutesUntilClose15Minutes() {
|
||||||
|
var lot = createLot(LocalDateTime.now().plusMinutes(15));
|
||||||
|
long minutes = lot.minutesUntilClose();
|
||||||
|
|
||||||
|
assertTrue(minutes >= 14 && minutes <= 16,
|
||||||
|
"Should be approximately 15 minutes, was: " + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
@DisplayName("Should calculate minutes until close for lot closing in 2 hours")
|
||||||
|
void testMinutesUntilClose2Hours() {
|
||||||
|
var lot = createLot(LocalDateTime.now().plusHours(2));
|
||||||
|
long minutes = lot.minutesUntilClose();
|
||||||
|
|
||||||
|
assertTrue(minutes >= 119 && minutes <= 121,
|
||||||
|
"Should be approximately 120 minutes, was: " + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
@DisplayName("Should return negative value for already closed lot")
|
||||||
|
void testMinutesUntilCloseNegative() {
|
||||||
|
var lot = createLot(LocalDateTime.now().minusHours(1));
|
||||||
|
long minutes = lot.minutesUntilClose();
|
||||||
|
|
||||||
|
assertTrue(minutes < 0,
|
||||||
|
"Should be negative for closed lots, was: " + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
@DisplayName("Should return MAX_VALUE when lot has no closing time")
|
||||||
|
void testMinutesUntilCloseNoTime() {
|
||||||
|
var lot = Lot.basic(100, 1001, "No closing time", "", "", "", 0, "General",
|
||||||
|
100.0, "EUR", "http://test.com/1001", null, false);
|
||||||
|
long minutes = lot.minutesUntilClose();
|
||||||
|
|
||||||
|
assertEquals(Long.MAX_VALUE, minutes,
|
||||||
|
"Should return MAX_VALUE when no closing time set");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
@DisplayName("Should identify lots closing within 5 minutes (critical threshold)")
|
||||||
|
void testCriticalClosingThreshold() {
|
||||||
|
var closing4Min = createLot(LocalDateTime.now().plusMinutes(4));
|
||||||
|
var closing5Min = createLot(LocalDateTime.now().plusMinutes(5));
|
||||||
|
var closing6Min = createLot(LocalDateTime.now().plusMinutes(6));
|
||||||
|
|
||||||
|
assertTrue(closing4Min.minutesUntilClose() < 5,
|
||||||
|
"Lot closing in 4 min should be < 5 minutes");
|
||||||
|
assertTrue(closing5Min.minutesUntilClose() >= 5,
|
||||||
|
"Lot closing in 5 min should be >= 5 minutes");
|
||||||
|
assertTrue(closing6Min.minutesUntilClose() > 5,
|
||||||
|
"Lot closing in 6 min should be > 5 minutes");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
@DisplayName("Should identify lots closing within 30 minutes (dashboard threshold)")
|
||||||
|
void testDashboardClosingThreshold() {
|
||||||
|
var closing20Min = createLot(LocalDateTime.now().plusMinutes(20));
|
||||||
|
var closing30Min = createLot(LocalDateTime.now().plusMinutes(30));
|
||||||
|
var closing40Min = createLot(LocalDateTime.now().plusMinutes(40));
|
||||||
|
|
||||||
|
assertTrue(closing20Min.minutesUntilClose() < 30,
|
||||||
|
"Lot closing in 20 min should be < 30 minutes");
|
||||||
|
assertTrue(closing30Min.minutesUntilClose() >= 30,
|
||||||
|
"Lot closing in 30 min should be >= 30 minutes");
|
||||||
|
assertTrue(closing40Min.minutesUntilClose() > 30,
|
||||||
|
"Lot closing in 40 min should be > 30 minutes");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(7)
|
||||||
|
@DisplayName("Should calculate correctly for lots closing soon (boundary cases)")
|
||||||
|
void testBoundaryCases() {
|
||||||
|
// Just closed (< 1 minute ago)
|
||||||
|
var justClosed = createLot(LocalDateTime.now().minusSeconds(30));
|
||||||
|
assertTrue(justClosed.minutesUntilClose() <= 0, "Just closed should be <= 0");
|
||||||
|
|
||||||
|
// Closing very soon (< 1 minute)
|
||||||
|
var closingVerySoon = createLot(LocalDateTime.now().plusSeconds(30));
|
||||||
|
assertTrue(closingVerySoon.minutesUntilClose() < 1, "Closing in 30 sec should be < 1 minute");
|
||||||
|
|
||||||
|
// Closing in exactly 1 hour
|
||||||
|
var closing1Hour = createLot(LocalDateTime.now().plusHours(1));
|
||||||
|
long minutes1Hour = closing1Hour.minutesUntilClose();
|
||||||
|
assertTrue(minutes1Hour >= 59 && minutes1Hour <= 61,
|
||||||
|
"Closing in 1 hour should be ~60 minutes, was: " + minutes1Hour);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(8)
|
||||||
|
@DisplayName("Multiple lots should sort correctly by urgency")
|
||||||
|
void testSortingByUrgency() {
|
||||||
|
var lot5Min = createLot(LocalDateTime.now().plusMinutes(5));
|
||||||
|
var lot30Min = createLot(LocalDateTime.now().plusMinutes(30));
|
||||||
|
var lot1Hour = createLot(LocalDateTime.now().plusHours(1));
|
||||||
|
var lot3Hours = createLot(LocalDateTime.now().plusHours(3));
|
||||||
|
|
||||||
|
var lots = java.util.List.of(lot3Hours, lot30Min, lot5Min, lot1Hour);
|
||||||
|
var sorted = lots.stream()
|
||||||
|
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
assertEquals(lot5Min, sorted.get(0), "Most urgent should be first");
|
||||||
|
assertEquals(lot30Min, sorted.get(1), "Second most urgent");
|
||||||
|
assertEquals(lot1Hour, sorted.get(2), "Third most urgent");
|
||||||
|
assertEquals(lot3Hours, sorted.get(3), "Least urgent should be last");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method
|
||||||
|
private Lot createLot(LocalDateTime closingTime) {
|
||||||
|
return Lot.basic(100, 1001, "Test Item", "", "", "", 0, "General",
|
||||||
|
100.0, "EUR", "http://test.com/1001", closingTime, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,7 +357,7 @@ class DatabaseServiceTest {
|
|||||||
100.0, "EUR", "https://example.com/" + i, null, false
|
100.0, "EUR", "https://example.com/" + i, null, false
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
fail("Thread 1 failed: " + e.getMessage());
|
fail("Thread 1 failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -370,7 +370,7 @@ class DatabaseServiceTest {
|
|||||||
200.0, "EUR", "https://example.com/" + i, null, false
|
200.0, "EUR", "https://example.com/" + i, null, false
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
fail("Thread 2 failed: " + e.getMessage());
|
fail("Thread 2 failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ class ImageProcessingServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should handle database error gracefully")
|
@DisplayName("Should handle database error gracefully")
|
||||||
void testProcessImageDatabaseError() throws SQLException {
|
void testProcessImageDatabaseError() {
|
||||||
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||||
|
|
||||||
when(mockDetector.detectObjects(normalizedPath))
|
when(mockDetector.detectObjects(normalizedPath))
|
||||||
.thenReturn(List.of("object"));
|
.thenReturn(List.of("object"));
|
||||||
|
|
||||||
doThrow(new SQLException("Database error"))
|
doThrow(new RuntimeException("Database error"))
|
||||||
.when(mockDb).updateImageLabels(anyInt(), anyList());
|
.when(mockDb).updateImageLabels(anyInt(), anyList());
|
||||||
|
|
||||||
// Should return false on error
|
// Should return false on error
|
||||||
@@ -162,9 +162,9 @@ class ImageProcessingServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should handle database query error in batch processing")
|
@DisplayName("Should handle database query error in batch processing")
|
||||||
void testProcessPendingImagesDatabaseError() throws SQLException {
|
void testProcessPendingImagesDatabaseError() {
|
||||||
when(mockDb.getImagesNeedingDetection())
|
when(mockDb.getImagesNeedingDetection())
|
||||||
.thenThrow(new SQLException("Database connection failed"));
|
.thenThrow(new RuntimeException("Database connection failed"));
|
||||||
|
|
||||||
// Should not throw exception
|
// Should not throw exception
|
||||||
assertDoesNotThrow(() -> service.processPendingImages());
|
assertDoesNotThrow(() -> service.processPendingImages());
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ class IntegrationTest {
|
|||||||
"https://example.com/60" + i, "A1", 5, null
|
"https://example.com/60" + i, "A1", 5, null
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
fail("Auction thread failed: " + e.getMessage());
|
fail("Auction thread failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -383,7 +383,7 @@ class IntegrationTest {
|
|||||||
100.0 * i, "EUR", "https://example.com/70" + i, null, false
|
100.0 * i, "EUR", "https://example.com/70" + i, null, false
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
fail("Lot thread failed: " + e.getMessage());
|
fail("Lot thread failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class ObjectDetectionServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should initialize with missing YOLO models (disabled mode)")
|
@DisplayName("Should initialize with missing YOLO models (disabled mode)")
|
||||||
void testInitializeWithoutModels() throws IOException {
|
void testInitializeWithoutModels() throws IOException {
|
||||||
// When models don't exist, service should initialize in disabled mode
|
|
||||||
ObjectDetectionService service = new ObjectDetectionService(
|
ObjectDetectionService service = new ObjectDetectionService(
|
||||||
"non_existent.cfg",
|
"non_existent.cfg",
|
||||||
"non_existent.weights",
|
"non_existent.weights",
|
||||||
@@ -84,7 +83,6 @@ class ObjectDetectionServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should throw IOException when model files exist but OpenCV fails to load")
|
@DisplayName("Should throw IOException when model files exist but OpenCV fails to load")
|
||||||
void testInitializeWithValidModels() throws IOException {
|
void testInitializeWithValidModels() throws IOException {
|
||||||
// Create dummy model files for testing initialization
|
|
||||||
var cfgPath = Paths.get(TEST_CFG);
|
var cfgPath = Paths.get(TEST_CFG);
|
||||||
var weightsPath = Paths.get(TEST_WEIGHTS);
|
var weightsPath = Paths.get(TEST_WEIGHTS);
|
||||||
var classesPath = Paths.get(TEST_CLASSES);
|
var classesPath = Paths.get(TEST_CLASSES);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class TroostwijkMonitorTest {
|
|||||||
void setUp() throws SQLException, IOException {
|
void setUp() throws SQLException, IOException {
|
||||||
testDbPath = "test_monitor_" + System.currentTimeMillis() + ".db";
|
testDbPath = "test_monitor_" + System.currentTimeMillis() + ".db";
|
||||||
|
|
||||||
// Initialize with non-existent YOLO models (disabled mode)
|
|
||||||
monitor = new TroostwijkMonitor(
|
monitor = new TroostwijkMonitor(
|
||||||
testDbPath,
|
testDbPath,
|
||||||
"desktop",
|
"desktop",
|
||||||
@@ -292,7 +291,7 @@ class TroostwijkMonitorTest {
|
|||||||
100.0, "EUR", "https://example.com/" + i, null, false
|
100.0, "EUR", "https://example.com/" + i, null, false
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
fail("Thread 1 failed: " + e.getMessage());
|
fail("Thread 1 failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -305,7 +304,7 @@ class TroostwijkMonitorTest {
|
|||||||
200.0, "EUR", "https://example.com/" + i, null, false
|
200.0, "EUR", "https://example.com/" + i, null, false
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
fail("Thread 2 failed: " + e.getMessage());
|
fail("Thread 2 failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user