Skip to content

Commit dce93f1

Browse files
v3.0.8
Signed-off-by: Dinger <quantdinger@gmail.com>
1 parent 2dd9fa0 commit dce93f1

58 files changed

Lines changed: 700 additions & 104 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
# ----- docker-compose.ghcr.yml (prebuilt images from GHCR) -----
3232
# Pin a specific image tag for backend + frontend (default: latest)
33-
# IMAGE_TAG=v3.0.7
33+
# IMAGE_TAG=v3.0.8
3434
# Override the GHCR image paths (e.g. for forks)
3535
# BACKEND_IMAGE=ghcr.io/brokermr810/quantdinger-backend
3636
# FRONTEND_IMAGE=ghcr.io/brokermr810/quantdinger-frontend

‎.github/workflows/docker-publish.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: Publish Docker images to GHCR
33
# Build and publish backend + frontend images to GitHub Container Registry.
44
#
55
# Triggers:
6-
# - push of a version tag matching `v*` (e.g. v3.0.7) → publishes semver + latest tags
6+
# - push of a version tag matching `v*` (e.g. v3.0.8) → publishes semver + latest tags
77
# - manual workflow_dispatch → publishes only a short-SHA tag for ad-hoc testing
88
#
99
# Images are built for linux/amd64 and linux/arm64 via QEMU + buildx. The backend

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646

4747
<p style="margin-top: 1.45rem; margin-bottom: 10px;">
4848
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square&logo=apache" alt="License"></a>
49-
<img src="https://img.shields.io/badge/Version-3.0.7-orange?style=flat-square" alt="Version">
49+
<img src="https://img.shields.io/badge/Version-3.0.8-orange?style=flat-square" alt="Version">
5050
<img src="https://img.shields.io/badge/Python-3.10%2B%20%7C%20Docker%20image%203.12-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python">
5151
<img src="https://img.shields.io/badge/Docker-Compose%20Ready-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker">
5252
<img src="https://img.shields.io/badge/Frontend-Prebuilt-1f8b4c?style=flat-square" alt="Frontend">
@@ -333,7 +333,7 @@ docker compose -f docker-compose.ghcr.yml up -d
333333
The backend entrypoint auto-generates a random `SECRET_KEY` on first start and applies the schema (`migrations/init.sql`) idempotently. Edit `backend.env` for persistent overrides (API keys, OAuth, broker credentials). Compose orchestration knobs go in a separate `.env` (optional) — e.g. pin a version:
334334

335335
```env
336-
IMAGE_TAG=v3.0.7
336+
IMAGE_TAG=v3.0.8
337337
# BACKEND_IMAGE=ghcr.io/<your-fork>/quantdinger-backend # optional, for forks
338338
# FRONTEND_IMAGE=ghcr.io/<your-fork>/quantdinger-frontend
339339
```
@@ -644,4 +644,8 @@ QuantDinger stands on top of a strong open-source ecosystem. Special thanks to p
644644
- [Capacitor](https://capacitorjs.com/)
645645
- [bip-utils](https://github.com/ebellocchia/bip_utils)
646646

647+
## P.S. — A Note on the Name
648+
649+
**QuantDinger** is a small tribute to **[Erwin Schrödinger](https://en.wikipedia.org/wiki/Erwin_Schr%C3%B6dinger)** — the "-dinger" in our name is the tail of "Schrödinger". The cat in the box was a thought experiment; every un-fired strategy is its own little version of it — simultaneously winning and losing until the order actually fills. Backtests open the box. Live trading collapses the wavefunction. Trade carefully.
650+
647651
<p align="center"><sub>If QuantDinger is useful to you, a GitHub star helps the project a lot.</sub></p>

‎backend_api_python/app/routes/settings.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ def _refresh_runtime_services() -> None:
141141
'key': 'BRAND_APP_VERSION',
142142
'label': 'App Version',
143143
'type': 'text',
144-
'default': '3.0.7',
145-
'description': 'Version label shown in the sidebar footer ("V3.0.7"). Frontend bundle version stays unchanged.'
144+
'default': '3.0.8',
145+
'description': 'Version label shown in the sidebar footer ("V3.0.8"). Frontend bundle version stays unchanged.'
146146
},
147147
{
148148
'key': 'BRAND_COPYRIGHT',
@@ -1478,7 +1478,7 @@ def get_public_config():
14781478
# fresh install still ships with sane copy / links instead of blanks.
14791479
_BRAND_DEFAULTS = {
14801480
'app_name': 'QuantDinger',
1481-
'app_version': '3.0.7',
1481+
'app_version': '3.0.8',
14821482
'copyright': '© 2025-2026 QuantDinger. All rights reserved.',
14831483
'contact_email': 'brokermr810@gmail.com',
14841484
'contact_support_url': 'https://t.me/quantdinger',

‎backend_api_python/app/routes/user.py‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,32 @@ def get_system_strategies():
14771477

14781478
# ==================== Admin Orders ====================
14791479

1480+
1481+
def _ensure_usdt_admin_columns():
1482+
"""Best-effort: extend qd_usdt_orders with admin-audit columns introduced
1483+
by the manual-confirm flow. ``ADD COLUMN IF NOT EXISTS`` is idempotent
1484+
on PostgreSQL, so this is effectively a no-op after the first hit.
1485+
1486+
Failures are swallowed (logged at debug level) so a running DB user
1487+
without DDL privileges doesn't block the read paths — the SELECTs
1488+
further down use ``information_schema`` checks or COALESCE to tolerate
1489+
the columns being absent.
1490+
"""
1491+
try:
1492+
with get_db_connection() as db:
1493+
cur = db.cursor()
1494+
cur.execute(
1495+
"ALTER TABLE qd_usdt_orders ADD COLUMN IF NOT EXISTS admin_note TEXT DEFAULT NULL"
1496+
)
1497+
cur.execute(
1498+
"ALTER TABLE qd_usdt_orders ADD COLUMN IF NOT EXISTS manual_confirmed_by INTEGER DEFAULT NULL"
1499+
)
1500+
db.commit()
1501+
cur.close()
1502+
except Exception as exc:
1503+
logger.debug("ensure_usdt_admin_columns skipped: %s", exc)
1504+
1505+
14801506
@user_bp.route('/admin-orders', methods=['GET'])
14811507
@login_required
14821508
@admin_required
@@ -1499,6 +1525,8 @@ def get_admin_orders():
14991525
page_size = min(100, max(1, page_size))
15001526
offset = (page - 1) * page_size
15011527

1528+
_ensure_usdt_admin_columns()
1529+
15021530
with get_db_connection() as db:
15031531
cur = db.cursor()
15041532

@@ -1539,6 +1567,9 @@ def get_admin_orders():
15391567
o.address,
15401568
o.tx_hash,
15411569
o.status,
1570+
o.matched_via,
1571+
o.admin_note,
1572+
o.manual_confirmed_by,
15421573
o.created_at,
15431574
o.paid_at,
15441575
o.confirmed_at,
@@ -1590,6 +1621,9 @@ def get_admin_orders():
15901621
'address': row.get('address') or '',
15911622
'tx_hash': row.get('tx_hash') or '',
15921623
'status': row.get('status') or '',
1624+
'matched_via': row.get('matched_via') or '',
1625+
'admin_note': row.get('admin_note') or '',
1626+
'manual_confirmed_by': row.get('manual_confirmed_by'),
15931627
'created_at': created_at,
15941628
'paid_at': paid_at,
15951629
'confirmed_at': confirmed_at,
@@ -1620,6 +1654,167 @@ def get_admin_orders():
16201654
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
16211655

16221656

1657+
@user_bp.route('/admin-orders/<int:order_id>/manual-confirm', methods=['POST'])
1658+
@login_required
1659+
@admin_required
1660+
def manual_confirm_order(order_id: int):
1661+
"""
1662+
Admin-only "rescue" lever for USDT orders.
1663+
1664+
Use case: the buyer paid the correct amount to the correct receiving
1665+
address, but the on-chain reconciler missed the transaction (RPC
1666+
outage, exotic wallet, chain-specific edge case, off-chain mistake
1667+
where the customer used a slightly different amount than the order
1668+
suffix demanded, etc.). Without this endpoint the admin's only option
1669+
is to ``UPDATE qd_usdt_orders ...`` by hand and then somehow trigger
1670+
``purchase_membership``; this surface does both atomically and leaves
1671+
an audit trail.
1672+
1673+
Body:
1674+
{
1675+
"tx_hash": "<on-chain tx hash>", # required
1676+
"note": "<free-form audit note>" # optional
1677+
}
1678+
1679+
Behavior:
1680+
- Flips the order to 'confirmed'.
1681+
- Stamps tx_hash + paid_at (if empty) + confirmed_at + admin_note
1682+
+ manual_confirmed_by + matched_via='manual_admin'.
1683+
- Calls ``purchase_membership`` exactly once per order (idempotent
1684+
on re-submit — already-confirmed orders only refresh the audit
1685+
fields, no double-grant).
1686+
- Refuses ``status='cancelled'`` orders so the admin doesn't
1687+
accidentally resurrect a deliberately-cancelled refund.
1688+
"""
1689+
try:
1690+
admin_user_id = getattr(g, 'user_id', None)
1691+
body = request.get_json(silent=True) or {}
1692+
tx_hash = (body.get('tx_hash') or '').strip()
1693+
note = (body.get('note') or '').strip()
1694+
1695+
if not tx_hash:
1696+
return jsonify({'code': 0, 'msg': 'missing_tx_hash', 'data': None}), 400
1697+
if len(tx_hash) > 120:
1698+
return jsonify({'code': 0, 'msg': 'tx_hash_too_long', 'data': None}), 400
1699+
if len(note) > 1000:
1700+
return jsonify({'code': 0, 'msg': 'note_too_long', 'data': None}), 400
1701+
1702+
_ensure_usdt_admin_columns()
1703+
1704+
# Load order in a short read txn (don't hold a lock across the
1705+
# billing call below — purchase_membership opens its own conn).
1706+
with get_db_connection() as db:
1707+
cur = db.cursor()
1708+
cur.execute(
1709+
"""
1710+
SELECT id, user_id, plan, status, chain
1711+
FROM qd_usdt_orders WHERE id = ?
1712+
""",
1713+
(order_id,),
1714+
)
1715+
order = cur.fetchone()
1716+
cur.close()
1717+
1718+
if not order:
1719+
return jsonify({'code': 0, 'msg': 'order_not_found', 'data': None}), 404
1720+
1721+
current_status = (order.get('status') or '').lower()
1722+
user_id = order.get('user_id')
1723+
plan = order.get('plan')
1724+
1725+
if current_status == 'cancelled':
1726+
# Cancelled orders are deliberately retired — surfacing this
1727+
# as an error forces the admin to recreate the order instead
1728+
# of silently rescuing a refunded one.
1729+
return jsonify({
1730+
'code': 0,
1731+
'msg': 'order_cancelled',
1732+
'data': {'order_id': order_id, 'status': current_status},
1733+
}), 400
1734+
1735+
already_confirmed = current_status == 'confirmed'
1736+
1737+
# Stamp confirmation + audit fields. COALESCE on paid_at /
1738+
# confirmed_at means re-running this for amendments (e.g. fix a
1739+
# typo in the tx hash) preserves the original timestamps.
1740+
with get_db_connection() as db:
1741+
cur = db.cursor()
1742+
cur.execute(
1743+
"""
1744+
UPDATE qd_usdt_orders
1745+
SET status = 'confirmed',
1746+
tx_hash = ?,
1747+
paid_at = COALESCE(paid_at, NOW()),
1748+
confirmed_at = COALESCE(confirmed_at, NOW()),
1749+
admin_note = ?,
1750+
manual_confirmed_by = ?,
1751+
matched_via = 'manual_admin',
1752+
updated_at = NOW()
1753+
WHERE id = ?
1754+
""",
1755+
(tx_hash, note or None, admin_user_id, order_id),
1756+
)
1757+
db.commit()
1758+
cur.close()
1759+
1760+
# Grant membership only when transitioning into 'confirmed' for
1761+
# the first time — re-submits (already confirmed) should only
1762+
# update the audit fields above, never grant another membership.
1763+
billing_msg = ''
1764+
if not already_confirmed:
1765+
try:
1766+
from app.services.billing_service import get_billing_service
1767+
billing = get_billing_service()
1768+
ok, billing_msg, _ = billing.purchase_membership(
1769+
int(user_id),
1770+
str(plan),
1771+
record_membership_order=False,
1772+
fulfillment_ref=f"manual_usdt:{order_id}:by_{admin_user_id}",
1773+
)
1774+
logger.info(
1775+
"[ManualConfirm] order=%s user=%s plan=%s admin=%s ok=%s msg=%s",
1776+
order_id, user_id, plan, admin_user_id, ok, billing_msg,
1777+
)
1778+
if not ok:
1779+
# Order row is already 'confirmed' at this point;
1780+
# surface the billing error so the admin knows to
1781+
# retry / dig in. We deliberately don't roll back the
1782+
# status because the on-chain payment IS real.
1783+
return jsonify({
1784+
'code': 0,
1785+
'msg': f'order_confirmed_but_billing_failed:{billing_msg}',
1786+
'data': {'order_id': order_id, 'billing_error': billing_msg},
1787+
}), 500
1788+
except Exception as exc:
1789+
logger.error(
1790+
"[ManualConfirm] billing exception order=%s err=%s",
1791+
order_id, exc, exc_info=True,
1792+
)
1793+
return jsonify({
1794+
'code': 0,
1795+
'msg': f'order_confirmed_but_billing_exception:{exc}',
1796+
'data': {'order_id': order_id},
1797+
}), 500
1798+
1799+
return jsonify({
1800+
'code': 1,
1801+
'msg': 'success',
1802+
'data': {
1803+
'order_id': order_id,
1804+
'user_id': user_id,
1805+
'plan': plan,
1806+
'status': 'confirmed',
1807+
'tx_hash': tx_hash,
1808+
'admin_note': note,
1809+
'manual_confirmed_by': admin_user_id,
1810+
'already_confirmed': already_confirmed,
1811+
},
1812+
})
1813+
except Exception as e:
1814+
logger.error(f"manual_confirm_order failed: {e}", exc_info=True)
1815+
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
1816+
1817+
16231818
# ==================== Admin AI Analysis Stats ====================
16241819

16251820
@user_bp.route('/admin-ai-stats', methods=['GET'])

0 commit comments

Comments
 (0)