Compare commits
13 Commits
ea7414dc7f
...
d38f50584c
| Author | SHA1 | Date | |
|---|---|---|---|
| d38f50584c | |||
| 1c67d7798a | |||
| 2a7bb0592d | |||
| 9699833ac9 | |||
| b3e6066c90 | |||
| 148b8e4011 | |||
| 1ef5edf939 | |||
| 76dbee7d41 | |||
| 148a9e0493 | |||
| 851a1bbc4f | |||
| aa1d21a616 | |||
| 7147e9930f | |||
| 85208da1b5 |
@@ -0,0 +1,81 @@
|
|||||||
|
name: Build and Deploy to Kubernetes
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- kub-stage
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: 194.5.195.53:30080
|
||||||
|
IMAGE_NAME: admin/backoffice
|
||||||
|
K8S_SERVER: 194.5.195.53
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: 194.5.195.53:32082/docker-sshpass:latest
|
||||||
|
options: --privileged
|
||||||
|
steps:
|
||||||
|
- name: Start Docker daemon
|
||||||
|
run: |
|
||||||
|
mkdir -p /etc/docker
|
||||||
|
cat > /etc/docker/daemon.json << 'DAEMON'
|
||||||
|
{
|
||||||
|
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
|
||||||
|
}
|
||||||
|
DAEMON
|
||||||
|
echo "🚀 Starting Docker daemon..."
|
||||||
|
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
|
||||||
|
|
||||||
|
# Wait up to 3 minutes for Docker to be ready
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if docker info >/dev/null 2>&1; then
|
||||||
|
echo "✅ Docker daemon is ready (attempt $i)"
|
||||||
|
docker version
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Final check
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "❌ Docker daemon failed to start after 3 minutes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/BackOffice.git .
|
||||||
|
|
||||||
|
- name: Login to Docker registries
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: |
|
||||||
|
cd src
|
||||||
|
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -f BackOffice/Dockerfile .
|
||||||
|
|
||||||
|
- name: Push to Registry
|
||||||
|
run: |
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
- name: Deploy to Kubernetes
|
||||||
|
run: |
|
||||||
|
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||||
|
|
||||||
|
# Copy K8s manifests to server
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/backoffice-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/backoffice-deployment.yaml
|
||||||
|
|
||||||
|
# Apply manifests and restart
|
||||||
|
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
||||||
|
kubectl apply -f /tmp/backoffice-deployment.yaml &&
|
||||||
|
kubectl rollout restart deployment/backoffice &&
|
||||||
|
kubectl rollout status deployment/backoffice --timeout=180s &&
|
||||||
|
rm -f /tmp/backoffice-deployment.yaml
|
||||||
|
"
|
||||||
|
echo "✅ Deployed!"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
# BackOffice UI Deployment (Blazor WASM + nginx)
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: backoffice
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: backoffice
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: backoffice
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: backoffice
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: backoffice
|
||||||
|
image: 194.5.195.53:30080/admin/backoffice:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: BLAZOR_ENVIRONMENT
|
||||||
|
value: "Staging"
|
||||||
|
- name: GW_URL
|
||||||
|
value: "https://cms.se.kbs1.ir"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry-secret
|
||||||
|
|
||||||
|
---
|
||||||
|
# BackOffice UI Service
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: backoffice-svc
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: backoffice
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
# BackOffice UI Ingress
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: backoffice-ingress
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/service-upstream: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- backoffice.se.kbs1.ir
|
||||||
|
secretName: backoffice-tls
|
||||||
|
rules:
|
||||||
|
- host: backoffice.se.kbs1.ir
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: backoffice-svc
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using BackOffice.Common.Utilities;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using BackOffice.Common.Utilities;
|
||||||
using Blazored.LocalStorage;
|
using Blazored.LocalStorage;
|
||||||
|
|
||||||
namespace BackOffice.Common.Utilities;
|
namespace BackOffice.Common.Utilities;
|
||||||
@@ -19,9 +20,41 @@ public class AppTokenProvider : ITokenProvider
|
|||||||
{
|
{
|
||||||
var authorizationToken = await _localStorage.GetItemAsync<string>(GlobalConstants.JwtTokenKey);
|
var authorizationToken = await _localStorage.GetItemAsync<string>(GlobalConstants.JwtTokenKey);
|
||||||
if (!string.IsNullOrEmpty(authorizationToken))
|
if (!string.IsNullOrEmpty(authorizationToken))
|
||||||
_token = authorizationToken.ToString().Replace("Bearer ", "");
|
{
|
||||||
|
var raw = authorizationToken.ToString().Replace("Bearer ", "");
|
||||||
|
// Don't send expired tokens — they cause 401 on anonymous endpoints like login
|
||||||
|
if (IsTokenExpired(raw))
|
||||||
|
{
|
||||||
|
await _localStorage.RemoveItemAsync(GlobalConstants.JwtTokenKey);
|
||||||
|
_token = null;
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
_token = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (IsTokenExpired(_token))
|
||||||
|
{
|
||||||
|
// Cached token has expired since last check
|
||||||
|
await _localStorage.RemoveItemAsync(GlobalConstants.JwtTokenKey);
|
||||||
|
_token = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _token;
|
return _token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsTokenExpired(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwt = handler.ReadJwtToken(token);
|
||||||
|
// Add 30-second buffer to avoid edge-case race
|
||||||
|
return jwt.ValidTo < DateTime.UtcNow.AddSeconds(-30);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Malformed token — treat as expired
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,14 +25,14 @@ RUN rm -rf /usr/share/nginx/html/*
|
|||||||
# Copy published wwwroot (Blazor WASM output)
|
# Copy published wwwroot (Blazor WASM output)
|
||||||
COPY --from=build /app/publish/wwwroot .
|
COPY --from=build /app/publish/wwwroot .
|
||||||
|
|
||||||
# Configure nginx for SPA routing
|
# Copy entrypoint script that sets blazor-environment header
|
||||||
RUN echo 'server { \
|
COPY ["BackOffice/docker-entrypoint.sh", "/docker-entrypoint.sh"]
|
||||||
listen 80; \
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
server_name _; \
|
|
||||||
location / { \
|
# Default Blazor environment (override via K8s env var BLAZOR_ENVIRONMENT)
|
||||||
root /usr/share/nginx/html; \
|
ENV BLAZOR_ENVIRONMENT=Staging
|
||||||
try_files $uri $uri/ /index.html; \
|
# Default gateway URL for gRPC calls (injected into appsettings at startup)
|
||||||
} \
|
ENV GW_URL=https://cms.se.kbs1.ir
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
CMD ["/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@using CMSMicroservice.Protobuf.Protos.ManualPayment
|
@using CMSMicroservice.Protobuf.Protos.ManualPayment
|
||||||
|
@using CMSMicroservice.Protobuf.Protos.Package
|
||||||
@using BackOffice.Pages.Payment.Components
|
@using BackOffice.Pages.Payment.Components
|
||||||
@using BackOffice.Pages.AutoComplete
|
@using BackOffice.Pages.AutoComplete
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@@ -13,15 +14,28 @@
|
|||||||
<UserAutoComplete Label="انتخاب کاربر"
|
<UserAutoComplete Label="انتخاب کاربر"
|
||||||
@bind-SelectedUserId="_selectedUserId" />
|
@bind-SelectedUserId="_selectedUserId" />
|
||||||
|
|
||||||
<MudNumericField T="long"
|
<MudSelect T="long?"
|
||||||
Label="مبلغ"
|
Label="انتخاب پکیج"
|
||||||
|
Value="@_selectedPackageId"
|
||||||
|
ValueChanged="OnPackageChanged"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Adornment="Adornment.End"
|
Margin="Margin.Dense"
|
||||||
AdornmentText="تومان"
|
|
||||||
Required="true"
|
Required="true"
|
||||||
RequiredError="مبلغ الزامی است"
|
RequiredError="انتخاب پکیج الزامی است">
|
||||||
Min="1"
|
@foreach (var pkg in _packages)
|
||||||
@bind-Value="_createModel.Amount" />
|
{
|
||||||
|
<MudSelectItem T="long?" Value="@((long?)pkg.Id)">
|
||||||
|
@pkg.Title — @pkg.Price.ToString("N0") تومان
|
||||||
|
</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
|
||||||
|
@if (_selectedPackagePrice.HasValue)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">
|
||||||
|
مبلغ پکیج: <strong>@_selectedPackagePrice.Value.ToString("N0") تومان</strong> — این مبلغ به کیفپول کاربر اضافه میشود.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
<MudSelect T="int"
|
<MudSelect T="int"
|
||||||
Label="نوع پرداخت"
|
Label="نوع پرداخت"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CMSMicroservice.Protobuf.Protos.ManualPayment;
|
using CMSMicroservice.Protobuf.Protos.ManualPayment;
|
||||||
|
using CMSMicroservice.Protobuf.Protos.Package;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Forms;
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
@@ -24,19 +25,23 @@ public partial class ManualPaymentDialog
|
|||||||
{
|
{
|
||||||
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
|
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
|
||||||
[Inject] public ManualPaymentContract.ManualPaymentContractClient ManualPaymentClient { get; set; } = default!;
|
[Inject] public ManualPaymentContract.ManualPaymentContractClient ManualPaymentClient { get; set; } = default!;
|
||||||
|
[Inject] public PackageContract.PackageContractClient PackageClient { get; set; } = default!;
|
||||||
// Snackbar is injected via _Imports.razor
|
// Snackbar is injected via _Imports.razor
|
||||||
|
|
||||||
[Parameter] public ManualPaymentDialogMode Mode { get; set; }
|
[Parameter] public ManualPaymentDialogMode Mode { get; set; }
|
||||||
[Parameter] public ManualPaymentModel? Model { get; set; }
|
[Parameter] public ManualPaymentModel? Model { get; set; }
|
||||||
|
|
||||||
private ManualPaymentModel _createModel = new(){
|
private ManualPaymentModel _createModel = new(){ Amount = 0 };
|
||||||
Amount = 0,
|
|
||||||
};
|
|
||||||
private string? _adminNote;
|
private string? _adminNote;
|
||||||
|
|
||||||
// User selection
|
// User selection
|
||||||
private long? _selectedUserId;
|
private long? _selectedUserId;
|
||||||
|
|
||||||
|
// Package selection
|
||||||
|
private long? _selectedPackageId;
|
||||||
|
private long? _selectedPackagePrice;
|
||||||
|
private List<GetAllPackageByFilterResponseModel> _packages = new();
|
||||||
|
|
||||||
// Type and Description
|
// Type and Description
|
||||||
private int _selectedType = 1; // Default: CashDeposit
|
private int _selectedType = 1; // Default: CashDeposit
|
||||||
private string _description = "عضویت دستی باشگاه مشتریان";
|
private string _description = "عضویت دستی باشگاه مشتریان";
|
||||||
@@ -48,6 +53,46 @@ public partial class ManualPaymentDialog
|
|||||||
|
|
||||||
private bool _isSubmitting;
|
private bool _isSubmitting;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadPackages();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPackages()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new GetAllPackageByFilterRequest
|
||||||
|
{
|
||||||
|
PaginationState = new() { PageNumber = 1, PageSize = 100 },
|
||||||
|
Filter = new()
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await PackageClient.GetAllPackageByFilterAsync(request);
|
||||||
|
_packages = result.Models?.OrderBy(p => p.SortOrder).ToList()
|
||||||
|
?? new List<GetAllPackageByFilterResponseModel>();
|
||||||
|
|
||||||
|
// اگر پکیج پایه وجود دارد، پیشفرض انتخاب کن
|
||||||
|
var basePackage = _packages.FirstOrDefault(p => p.IsBasePackage);
|
||||||
|
if (basePackage != null)
|
||||||
|
{
|
||||||
|
_selectedPackageId = basePackage.Id;
|
||||||
|
_selectedPackagePrice = basePackage.Price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_packages = new List<GetAllPackageByFilterResponseModel>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPackageChanged(long? packageId)
|
||||||
|
{
|
||||||
|
_selectedPackageId = packageId;
|
||||||
|
var pkg = _packages.FirstOrDefault(p => p.Id == packageId);
|
||||||
|
_selectedPackagePrice = pkg?.Price;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnImageFileSelected(IBrowserFile? file)
|
private async Task OnImageFileSelected(IBrowserFile? file)
|
||||||
{
|
{
|
||||||
if (file == null)
|
if (file == null)
|
||||||
@@ -81,6 +126,12 @@ public partial class ManualPaymentDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_selectedPackageId.HasValue || _selectedPackageId.Value <= 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_selectedType <= 0)
|
if (_selectedType <= 0)
|
||||||
{
|
{
|
||||||
Snackbar.Add("لطفاً نوع پرداخت را انتخاب کنید.", Severity.Warning);
|
Snackbar.Add("لطفاً نوع پرداخت را انتخاب کنید.", Severity.Warning);
|
||||||
@@ -101,7 +152,8 @@ public partial class ManualPaymentDialog
|
|||||||
var request = new CreateManualPaymentRequest
|
var request = new CreateManualPaymentRequest
|
||||||
{
|
{
|
||||||
UserId = _selectedUserId.Value,
|
UserId = _selectedUserId.Value,
|
||||||
Amount = _createModel.Amount,
|
Amount = 0, // deprecated — مبلغ از پکیج خوانده میشود
|
||||||
|
PackageId = _selectedPackageId.Value,
|
||||||
Type = (ManualPaymentType)_selectedType,
|
Type = (ManualPaymentType)_selectedType,
|
||||||
Description = _description
|
Description = _description
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Generate nginx config with blazor-environment header
|
||||||
|
# BLAZOR_ENVIRONMENT defaults to "Staging" if not set
|
||||||
|
BLAZOR_ENV="${BLAZOR_ENVIRONMENT:-Staging}"
|
||||||
|
|
||||||
|
cat > /etc/nginx/conf.d/default.conf <<NGINX_CONF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
add_header blazor-environment $BLAZOR_ENV;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX_CONF
|
||||||
|
|
||||||
|
# Inject GW_URL into all appsettings JSON files
|
||||||
|
# This overrides GwUrl regardless of which environment Blazor detects,
|
||||||
|
# which is critical because the service worker cache strips the
|
||||||
|
# blazor-environment header and Blazor falls back to Production.
|
||||||
|
WWWROOT="/usr/share/nginx/html"
|
||||||
|
if [ -n "$GW_URL" ]; then
|
||||||
|
echo "[entrypoint] Setting GwUrl to $GW_URL in all appsettings files"
|
||||||
|
for f in "$WWWROOT"/appsettings*.json; do
|
||||||
|
[ -f "$f" ] && sed -i "s|\"GwUrl\":\s*\"[^\"]*\"|\"GwUrl\": \"$GW_URL\"|g" "$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
@@ -9,7 +9,7 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
|
|||||||
const cacheNamePrefix = 'offline-cache-';
|
const cacheNamePrefix = 'offline-cache-';
|
||||||
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
|
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
|
||||||
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
|
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
|
||||||
const offlineAssetsExclude = [ /^service-worker\.js$/ ];
|
const offlineAssetsExclude = [ /^service-worker\.js$/, /^appsettings.*\.json$/ ];
|
||||||
|
|
||||||
async function onInstall(event) {
|
async function onInstall(event) {
|
||||||
console.info('Service worker: Install');
|
console.info('Service worker: Install');
|
||||||
|
|||||||
Reference in New Issue
Block a user