Orta
veri-isleme/excel-ice-disa-aktarma
Excel İçe / Dışa Aktarma
- PHP 8
- PDO
- MySQL
- Ajax
- +4
173 inceleme 90 canlı açılış 3 beğeni
Veri İşleme Orta
Sütun eşleme, ayraç ve kodlama tespiti, dry-run önizleme, atomik veya kısmi commit ve indirilebilir hata raporu — PHP PDO, Ajax, DataTables ile.
Veritabanı şeması projedeki cy_csv_import.sql dosyasında.
Neredeyse her panelde bir "CSV yükle" düğmesi vardır ve arkasında genellikle tek bir döngü durur: dosyayı aç, satırları oku, hepsini INSERT et.
O döngü 800. satırda patladığında ilk 799 kayıt yarım girmiştir. Kullanıcı ne olduğunu göremez, hangi satırın bozuk olduğunu bilemez ve yapabileceği tek şey aynı dosyayı ikinci kez yüklemektir — bu sefer veriler ikiye katlanır. Böyle bir ekran bir içe aktarma aracı değil, kontrolsüz bir yazma noktasıdır.
Bu proje aynı işi üç adıma böler ve her adımın neden ayrı olduğunu anlatır:
Ve dördüncü bir şey daha yapar: her aktarmanın künyesini tutar. Üç hafta sonra "bu 400 kayıt nereden geldi?" diye sorulduğunda cevap ekranda durur — hangi dosya, hangi ayraç, hangi modda, kaç satır girdi, kaç tanesi atlandı.
Kimler için uygun?
; ayraçlı, ISO-8859-9 kodlamalı dosyalarla boğuşanlarKlonla, cy_import.sql'i içe aktar, çalıştır. Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.
Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.
İçe aktarma ekranlarının klasik kusuru, kullanıcıyı karanlıkta karar vermeye zorlamasıdır. "Yükle" düğmesine basana kadar dosyanın kaç satırının bozuk olduğu bilinmez; basıldıktan sonra da geri dönüş yoktur.
preview uç noktası dosyanın tamamını commit ile birebir aynı kodla (validate_row()) işler, ama tek bir INSERT çalıştırmaz:
// handle_preview() — hiçbir yazma yok, yalnızca sayım
$summary = ['total' => 0, 'ok' => 0, 'warning' => 0,
'error' => 0, 'dupe_file' => 0, 'dupe_db' => 0];Her kusuru hataya çevirmek kolaydır ama pahalıdır: %2'lik kirli veri yüzünden %98'lik temiz veriyi de reddetmek demektir.
// Ad yoksa satır YAZILAMAZ → hata
if (mb_strlen($name) < 2) {
return ['data' => [], 'error' => 'Ad en az 2 karakter olmalı', ...];
}
// Telefon tuhafsa satır yine de değerlidir → uyarı
if (strlen($digits) !== 10) {
return [$digits, 'Telefon 10 hane değil (yine de kaydedilecek)'];
}"Bu e-posta zaten var" cümlesi üç ayrı duruma karşılık gelir ve üçünün de çözümü farklıdır:
| Durum | Ne demek? | Ne yapılır? |
|---|---|---|
dupe_file | Aynı e-posta dosya içinde iki kez geçiyor | İlk görülen kazanır; ikincisi atlanır ve raporlanır |
dupe_db + dedupe=skip | Kayıt veritabanında zaten var | Mevcut kayda dokunulmaz; var olan veri kaynak sayılır |
dupe_db + dedupe=update | Kayıt var, ama CSV daha güncel | Mevcut kayıt CSV ile tazelenir; dosya kaynak sayılır |
Son satır bir tercih değil, verinin kime ait olduğuyla ilgili bir karardır: telefon güncelleme dosyasında CSV haklıdır, müşteri listesi eklemede veritabanı haklıdır. Bu yüzden seçim kullanıcıya bırakılır ve önizleme, seçim değiştiğinde anında yeniden hesaplanır.
Ama asıl koruma uygulama kodunda değil, veritabanı kısıtındadır:
UNIQUE KEY `uq_contacts_email` (`email`)SELECT, yoksa INSERT" kontrolü iki eşzamanlı istekte yarışır ve kaybeder; UNIQUE indeks kaybetmez. Kod yine de önce SELECT yapar — ama bunu önizleme için yapar. Yazma anındaki gerçek koruma indeksin kendisidir ve yarış durumunda dönen hata indeks adına bakılarak ayırt edilir:
$isDupeEmail = $ex->getCode() === '23000'
&& str_contains($ex->getMessage(), 'uq_contacts_email');
if (!$isDupeEmail) {
throw $ex; // başka bir kısıt ihlali — yanlış mesajla gizleme
}
import_runs satırı, kişilerle aynı transaction içinde açılır ve aynı transaction içinde sayaçlarıyla güncellenir:
$db->beginTransaction();
$runStmt->execute([...]); // künye açılır (boş sayaçlarla)
$runId = (int) $db->lastInsertId();
foreach ($rows as $raw) { ... } // kişiler bu künyeye bağlanır
$db->prepare('UPDATE import_runs SET inserted = ...')->execute([...]);
$db->commit();rollBack() künyeyi de siler; bu yüzden hemen ardından ayrı bir aborted künyesi yazılır: denenip iptal edilen aktarma da geçmişte görünmelidir.
İçe aktarma çekirdeği
upload → preview → commit, ; TAB | — tırnak içi ayraçlar sayılmazeposta, mail, e-posta…)+90, 0, boşluk, tire → 10 haneEkranlar ve altyapı
import_id#kisi-4, #aktarma-3confirm() yok)aria-liveBu projede saldırı yüzeyi alışılmadık biçimde geniştir: içeriği tamamen kullanıcının belirlediği bir dosyayı okuyup ekrana basıyor ve veritabanına yazıyoruz.
| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
| SQL Injection | "... WHERE city = '".$_POST['city']."'" | Tüm sorgular prepared statement, EMULATE_PREPARES = false. Sıralama sütunu beyaz listeden geçer; source, mode, dedupe ve status değerleri sabit listelerle doğrulanır. |
| XSS (önizleme tablosunda) | $('#cell').html(row.name) | Önizlemedeki her hücre doğrudan yüklenen dosyadan gelir. Sunucuda e(), istemcide esc(). Bir CSV hücresinde <script> bulunması kural dışı değil, beklentidir. |
| CSV formül enjeksiyonu | Hücreyi olduğu gibi yazmak | =, +, -, @ ile başlayan hücrelerin başına tek tırnak konur. Hata raporu saldırganın kendi metnini geri yazdığı için burada risk teorik değildir. |
| Path traversal | IMPORT_DIR.'/'.$_POST['token'].'.csv' | Token yalnızca onaltılık olabilir (ctype_xdigit), ayrıca dosya adı session_id() ile öneklenir — token'ı ele geçiren başka bir ziyaretçi bile dosyaya ulaşamaz. |
| Yüklenen dosyanın çalıştırılması | Dosyayı web köküne yazmak | Dosyalar storage/imports/ altına gider; klasörde Require all denied vardır ve içerik hiçbir zaman include edilmez. |
| Uzantıya güvenmek | if ($ext === 'csv') | Tür içerikten okunur (finfo); text/* dışındaki türler reddedilir. |
| CSRF | (genelde hiç yok) | Oturuma bağlı 32 baytlık token, her yazma isteğinde, hash_equals() ile sabit zamanlı doğrulama. Token'sız istek → HTTP 403. |
| İzi olmayan kayıt | Önce INSERT, sonra ayrı bir INSERT | Kişiler ve içe aktarma künyesi tek transaction. Künye yazılamazsa kişiler de geri alınır. |
| Yarış durumunda çift kayıt | "Önce SELECT, yoksa INSERT" | uq_contacts_email benzersiz indeksi. 23000 hatası indeks adına bakılarak ayırt edilir; başka bir kısıt ihlali yutulmaz. |
| Bozuk kodlamanın yanıtı yutması | json_encode() → false → boş gövde | Dosya okunurken UTF-8'e çevrilir (csv_to_utf8()); ikinci hat olarak JSON_INVALID_UTF8_SUBSTITUTE. Tek bayt yüzünden 5000 satır kaybolmaz. |
| Kaynak tüketimi (DoS) | while (fgetcsv(...)) sınırsız | IMPORT_MAX_ROWS (5000), IMPORT_MAX_BYTES (5 MB), PAGE_SIZE_MAX (200), EXPORT_MAX_ROWS (5000) ve dört ayrı hız sınırı kovası. |
| Sahipsiz dosya birikmesi | Yüklenen dosyayı silmemek | İş biter bitmez unlink(); ayrıca her istekte fırsatçı gc, IMPORT_TTL_MIN (30 dk) dolmuş dosyaları temizler. |
| LIKE joker karakterleri | LIKE '%$search%' | %, _, \ kaçışlanır. |
| Bilgi sızıntısı | Ekrana basılan MySQL hataları | APP_DEBUG sunucu adından türetilir; canlıda otomatik false, detay error_log()'a gider. |
| Kurulum dosyasının indirilmesi | /cy_import.sql → HTTP 200 | .htaccess: .sql, .md, .json, .log … kapalı (README dosyaları bilinçli istisnadır). |
Tüm ayarlar system/config.php içindedir.
| Sabit | Varsayılan | Ne işe yarar | |
|---|---|---|---|
IMPORT_MAX_ROWS | 5000 | Bir dosyada işlenecek en fazla veri satırı | |
IMPORT_MAX_BYTES | 5 MB | Yüklenecek dosya boyutu tavanı | |
IMPORT_DIR | storage/imports | Geçici dosya klasörü (web'e kapalı) | |
IMPORT_TTL_MIN | 30 | Sahipsiz geçici dosyaların ömrü (dakika) | |
PREVIEW_MAX_ROWS | 200 | Önizleme tablosunda gösterilecek en fazla satır | |
DEDUPE_DEFAULT | skip | Açılıştaki tekrar politikası (skip \ | update) |
TARGET_FIELDS | name, email, phone, city | Eşlenebilecek hedef alanlar | |
REQUIRED_FIELDS | name, email | Eşlenmeden aktarma başlatılamayan alanlar | |
PAGE_SIZE_MAX | 200 | DataTables sayfa boyutu tavanı | |
EXPORT_MAX_ROWS | 5000 | CSV dışa aktarımda satır tavanı | |
RATE_LIMIT_* | [istek, saniye] | upload / preview / commit / export için ayrı kovalar |
TARGET_FIELDS dizisine bir ad eklemek, eşleme <select>'ini, sunucu doğrulamasını ve hata raporu başlıklarını birlikte günceller. Üç yer daha dokunulması gerekir:
field_label() — Türkçe etiketi (system/function.php)validate_row() — varsa doğrulama kuralıINSERT / UPDATE sorguları (system/ajax.php) ve contacts tablosunda sütunTakma ad tahmini için assets/js/import.js içindeki ALIASES nesnesine de bir satır ekleyin.
Canlı veritabanı künyesi config.php'ye yazılmaz: o dosya depoda durur ve her dağıtımda depodaki sürümle değiştirilir. Bunun yerine yanına config.local.php koyun (.gitignore içindedir):
cp system/config.local.php.example system/config.local.php
# sonra dört satırı doldurun# Linux / macOS
DB_HOST=127.0.0.1 DB_NAME=cy_import DB_USER=uygulama DB_PASS=gizli php -S 127.0.0.1:8000
# Windows (PowerShell)
$env:DB_PASS="gizli"; php -S 127.0.0.1:8000config.local.php → ortam değişkeni → yerel varsayılan.
İçe aktarma katmanını kendi tablonuza taşımak için sırayla:
1 — Hedef alanlarınızı tanımlayın
// system/config.php
define('TARGET_FIELDS', ['ad', 'stok_kodu', 'fiyat', 'adet']);
define('REQUIRED_FIELDS', ['ad', 'stok_kodu']);validate_row() içindeki kuralları kendi alanlarınıza uyarlayın. Hata / uyarı ayrımını koruyun: kaydın kimliğini kuran alan eksikse hata, zenginleştiren alan kusurluysa uyarı.
3 — Benzersizlik anahtarınızı seçin
Bu projede email, sizde stok_kodu olabilir. Üç yeri birlikte değiştirin:
UNIQUE KEY `uq_urun_stok_kodu` (`stok_kodu`)// handle_preview: SELECT stok_kodu FROM urunler WHERE stok_kodu IN (…)
// handle_commit : str_contains($ex->getMessage(), 'uq_urun_stok_kodu')INSERT / UPDATE sorgularını değiştirin
handle_commit() içindeki iki prepared statement. Döngünün dışında hazırlanmalarını koruyun; içeride hazırlamak 5000 satırda 5000 gereksiz gidiş-dönüş demektir.
5 — Künye tablosunu olduğu gibi alın
import_runs tablosu alanlarınızdan bağımsızdır; hiç değiştirmeden kullanabilirsiniz.
Atlamayın:previewvecommitaynıvalidate_row()fonksiyonunu çağırmalıdır. İkisini ayırdığınız gün önizleme yalan söylemeye başlar.
assets/css/cilginyazilim.css dosyası bu projeye değil markaya aittir. Diğer örnek projelerde de aynı görsel dili kullanabilmek için ayrı bir dosya olarak tutulur; bu örneğe özgü her şey (sihirbaz adımları, bırakma alanı, önizleme tablosu, özet çipleri) assets/css/style.css içindedir.
| Sınıf | Ne işe yarar | ||
|---|---|---|---|
.cy-card / .cy-card__header / __body / __footer | Gradyan başlıklı ana kart | ||
.cy-brand / .cy-brand__mark / __title / __subtitle | Logo kutusu + başlık bloğu | ||
.cy-btn + --primary \ | --onbrand \ | --glass | Marka butonları |
.cy-btn-icon + --view \ | --edit \ | --delete | Tablo içi ikon butonları |
.cy-table / .cy-actions / .cy-id / .cy-name | Marka görünümlü tablo bileşenleri | ||
.cy-badge + --glass \ | --soft | Rozetler | |
.cy-modal / .cy-detail | Gradyan başlıklı modal ve etiket/değer listesi | ||
.cy-toast + --success \ | --danger \ | --info | Bildirim balonları |
Bu örneğe özgü (style.css): .cy-step / .cy-step__num, .cy-dropzone, .cy-filemeta / .cy-fm, .cy-chip, .cy-verdict, .cy-preview, .cy-choice, .cy-result, .cy-op--ok|warning|error|dupe_db|success|partial|aborted, .cy-src, .cy-count.
Tüm bileşenler CSS değişkenlerinden beslenir. Tek yeri değiştirmek yeter:
:root {
--cy-brand-900: #061321; /* Logodaki en koyu lacivert */
--cy-brand-600: #0b5cb5; /* Ana marka mavisi */
--cy-accent: #0ea5e9; /* Vurgu rengi */
--cy-gradient: linear-gradient(135deg, #061321, #0b5cb5 45%, #0284c7);
}
Kullanıcının işletim sistemi koyu temadaysa otomatik devreye girer. Zorlamak isterseniz:
<html data-cy-theme="dark"> <!-- veya "light" -->
csv-import/
├── index.php → Arayüz. Veritabanına DOKUNMAZ; üç kart + üç modal.
├── cy_import.sql → Şema + 33 kişi + 6 içe aktarma künyesi (NOW() - INTERVAL)
├── sample.csv → Örnek dosya: her durumu temsil eden 14 satır
├── .htaccess → Dizin listeleme kapalı, .sql/.md kapalı (README istisna)
│
├── system/
│ ├── config.php → Ayarlar, PDO bağlantısı, APP_DEBUG türetimi
│ ├── config.local.php → (siz oluşturursunuz) canlı künye — .gitignore'da
│ ├── config.local.php.example
│ ├── function.php → CSRF, hız sınırı, CSV okuma/yazma, doğrulama
│ ├── ajax.php → 12 uç nokta (sihirbaz + ekranlar)
│ └── .htaccess → BEYAZ LİSTE: yalnızca ajax.php dışarı açık
│
├── storage/imports/ → Geçici CSV'ler. Web'e KAPALI, 30 dk sonra temizlenir.
│ ├── .htaccess → Require all denied
│ └── .gitignore → yüklenen dosyalar depoya girmez
│
├── assets/
│ ├── css/
│ │ ├── cilginyazilim.css → MARKA KALIBI (projeler arası ortak — dokunmayın)
│ │ ├── style.css → Yalnızca bu örneğe özgü stiller
│ │ ├── bootstrap.min.css
│ │ └── dataTables.bootstrap5.min.css
│ ├── js/
│ │ ├── import.js → Sihirbaz + tablolar + modallar (12 bölüm)
│ │ ├── jquery-3.7.0.js
│ │ ├── bootstrap.bundle.js
│ │ └── jquery.dataTables.min.js
│ └── images/logo.png
│
├── docs/screenshots/ → README'de kullanılan görseller
├── CHANGELOG.md
├── README.md · README.en.md
└── LICENSE → MIT
┌─ ADIM 1: upload ─────────────────────────────────────────────────────┐
│ · içerikten MIME kontrolü + boyut sınırı │
│ · csv_to_utf8() → BOM / UTF-8 geçerliliği / Windows-1254 │
│ · detect_delimiter() → , ; TAB | (tırnak içi ayraçlar sayılmaz) │
│ · fgetcsv ile ayrıştır, IMPORT_MAX_ROWS tavanını uygula │
│ · storage/imports/<session_id>_<token>.csv olarak sakla │
│ ↳ döner: token · başlık · ilk 5 satır · ayraç · kodlama · satır sayısı│
└──────────────────────────────────────────────────────────────────────┘
│ token
▼
┌─ ADIM 2: preview (DRY-RUN — VERİTABANINA HİÇBİR ŞEY YAZILMAZ) ──────┐
│ · load_and_map() → mapping doğrula (zorunlu alanlar, sütun var mı?) │
│ · dosyadaki e-postaları TEK sorguda sor: │
│ SELECT email FROM contacts WHERE email IN (…) ← 1 sorgu │
│ · her satır → validate_row() │
│ ok · warning · error · dupe_file · dupe_db │
│ ↳ döner: özet sayaçları · ilk 200 satırın önizlemesi · atomik uyarısı│
└──────────────────────────────────────────────────────────────────────┘
│ token + mapping + mode + dedupe
▼
┌─ ADIM 3: commit ─────────────────────────────────────────────────────┐
│ BEGIN │
│ INSERT import_runs (…) ← künye açılır, id alınır │
│ foreach satır: │
│ validate_row() │
│ ├─ hata + mode=atomic → ROLLBACK → 409 → aborted künyesi │
│ ├─ hata + mode=kısmi → hata raporuna ekle, atla │
│ ├─ DB'de var + skip → atla ve raporla │
│ ├─ DB'de var + update → UPDATE contacts │
│ └─ yeni → INSERT contacts (import_id = künye) │
│ UPDATE import_runs SET inserted, updated, skipped, status, … │
│ COMMIT │
│ ↳ hata varsa rapor CSV'si yazılır, kaynak dosya SİLİNİR │
└──────────────────────────────────────────────────────────────────────┘
| Katman | Dosya | Sorumluluk |
|---|---|---|
| Sunum | index.php | Yalnızca HTML iskeleti. Veri yok, sorgu yok. |
| Arayüz mantığı | assets/js/import.js | Sihirbaz durumu, DataTables, modallar, kaçışlama |
| Uç noktalar | system/ajax.php | İstek doğrulama, işlem akışı, JSON yanıtı |
| Alan mantığı | system/function.php | CSV okuma, kodlama, doğrulama, CSV yazma |
| Ayar | system/config.php | Sabitler, PDO, APP_DEBUG türetimi |
Tüm istekler system/ajax.php adresine gider. İndirme uçları (error_csv, contacts_export) dışında POST ve CSRF token zorunludur.
upload · upload_sample — dosya yükle
multipart/form-data ile file alanı. upload_sample dosya beklemez; depodaki sample.csv ile aynı kod yolunu çalıştırır.
{
"success": true,
"token": "84351d3b8bb2c4ea6421ea499e87aaec",
"filename": "sample.csv",
"delimiter": ";",
"encoding": "UTF-8",
"header": ["ad soyad", "e-posta", "telefon", "şehir"],
"sample": [["Zeynep Kaya", "[email protected]", "0532 111 22 44", "Bursa"]],
"row_count": 14,
"targets": [{"key": "name", "label": "Ad Soyad", "required": true}]
}
preview — dry-run
İstek: token, mapping (JSON: {"name":0,"email":1,…}), dedupe
{
"success": true,
"summary": {"total": 14, "ok": 6, "warning": 1, "error": 3, "dupe_file": 1, "dupe_db": 3},
"will_insert": 7,
"will_update": 0,
"will_skip": 7,
"preview": [
{"line": 2, "status": "dupe_db", "status_label": "Veritabanında var",
"note": "Veritabanında var → ATLANACAK",
"data": {"name": "Zeynep Kaya", "email": "[email protected]", "phone": "5321112244", "city": "Bursa"},
"raw": []}
],
"preview_shown": 14,
"atomic_hint": "Atomik modda 3 hatalı satır TÜM aktarımı iptal eder."
}data boşsa satır doğrulamayı geçememiştir; o durumda raw dosyadaki ham hücreleri taşır.
commit — gerçek aktarma
İstek: token, mapping, mode (atomic \| skip_invalid), dedupe (skip \| update)
{
"success": true,
"run_id": 9,
"mode": "skip_invalid",
"mode_label": "Kısmi (hatalıları atla)",
"inserted": 7,
"updated": 0,
"skipped": 7,
"error_count": 7,
"duration_ms": 10,
"status": "partial",
"report_token": "3efdc2a4cce2e44e097f1ad25ac72d4e"
}{"success": false, "description": "Atomik mod: 4. satır hatalı (Geçersiz e-posta). HİÇBİR kayıt yazılmadı.",
"line": 4, "reason": "Geçersiz e-posta", "mode": "atomic"}
error_csv — hata raporu (GET)
?action=error_csv&token=<report_token> → UTF-8 BOM'lu, ; ayraçlı CSV indirir.
satir;hata;"Ad Soyad";E-posta;Telefon;Şehir
4;"Geçersiz e-posta";"Hatalı Kayıt";gecersiz-eposta;000;Konya
10;"Geçersiz e-posta";"'=HYPERLINK(""http://kotu.example.com"",""Fatura"")";sahte-eposta@;5551234567;Rizestats — sayaç şeridi
{"success": true, "contacts": 33, "from_import": 30, "cities": 16,
"runs": 6, "skipped": 13, "errors": 8, "last_run": "29.08.2026 14:29",
"cities_list": ["Adana", "Ankara", "…"]}
contacts_list · contact_detail · contact_delete · contacts_export
contacts_list standart DataTables server-side yanıtı döner (draw, recordsTotal, recordsFiltered, data). Filtreler: search[value], f_city, f_source, f_run.
contacts_export GET'tir ve contact_filters() fonksiyonunu listeyle paylaşır — indirilen dosya ekranda görünenle birebir aynıdır.
runs_list · run_detail
runs_list filtresi: f_status (success \| partial \| aborted).
run_detail künyeye ek olarak still_here döner: o aktarmadan gelip hâlâ duran kayıt sayısı.
| Kod | Anlamı |
|---|---|
200 | İşlem başarılı |
400 | Geçersiz parametre (örn. hatalı ID) |
403 | CSRF token geçersiz veya oturum düşmüş |
404 | Kayıt / rapor bulunamadı |
405 | POST dışı istek |
409 | Atomik fren — dosyada hatalı satır var, hiçbir şey yazılmadı |
410 | Geçici dosyanın süresi dolmuş, yeniden yükleyin |
422 | Doğrulama hatası (dosya türü, boyut, eksik eşleme — errors alanı döner) |
429 | Hız sınırı aşıldı (retry_after saniye döner) |
500 | Sunucu / veritabanı hatası |
CREATE TABLE `import_runs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`filename` VARCHAR(190) NOT NULL,
`delimiter_label` VARCHAR(8) NOT NULL DEFAULT ',',
`encoding` VARCHAR(24) NOT NULL DEFAULT 'UTF-8',
`mode` ENUM('atomic','skip_invalid') NOT NULL DEFAULT 'skip_invalid',
`dedupe` ENUM('skip','update') NOT NULL DEFAULT 'skip',
`total_rows` INT UNSIGNED NOT NULL DEFAULT 0,
`inserted` INT UNSIGNED NOT NULL DEFAULT 0,
`updated` INT UNSIGNED NOT NULL DEFAULT 0,
`skipped` INT UNSIGNED NOT NULL DEFAULT 0,
`error_count` INT UNSIGNED NOT NULL DEFAULT 0,
`duration_ms` INT UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('success','partial','aborted') NOT NULL DEFAULT 'success',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_runs_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `contacts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(150) NOT NULL,
`email` VARCHAR(190) NOT NULL,
`phone` VARCHAR(20) NOT NULL DEFAULT '',
`city` VARCHAR(80) NOT NULL DEFAULT '',
`source` ENUM('manual','import') NOT NULL DEFAULT 'import',
`import_id` INT UNSIGNED NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_contacts_email` (`email`),
KEY `idx_contacts_city` (`city`),
KEY `idx_contacts_import` (`import_id`),
CONSTRAINT `fk_contacts_import` FOREIGN KEY (`import_id`)
REFERENCES `import_runs` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
| Karar | Neden |
|---|---|
uq_contacts_email | "Aynı dosyayı iki kez yükledim, kayıtlar ikiye katlandı" sorununun tek gerçek çözümü. Uygulama katmanındaki kontrol yarışır ve kaybeder; indeks kaybetmez |
delimiter_label, delimiter değil | DELIMITER, mysql istemcisinin komutudur. Satır başında geçtiğinde kurulum dosyası sessizce bozulur |
status ENUM (3 değer) | partial ile aborted farklı sorulara cevap verir: biri "kısmen girdi", diğeri "hiç girmedi". Tek bir success bayrağı bu ayrımı yutardı |
ON DELETE SET NULL | Künye silinse bile kişi kaydı durur, yalnızca izi kopar. CASCADE olsaydı bir künye silmek 400 kişiyi de silerdi |
source + import_id birlikte | import_id NULL olabilir (elle girilmiş ya da künyesi silinmiş). source bu iki durumu ayırt eder |
duration_ms | Performansı ölçülebilir kılar: 5000 satırlık bir aktarmanın 4 saniye mi 40 saniye mi sürdüğü, indeks kararlarının tek somut geri bildirimidir |
updated_at ON UPDATE | dedupe=update politikasının ne yaptığı veriden okunabilir: oluşturma ve güncelleme zamanı farklı olan kayıtlar tazelenmiş olanlardır |
| InnoDB | Transaction desteği — künye ile kişilerin birlikte yazılabilmesinin ön şartı |
Neden dosyayı iki kez yüklemiyoruz — önizleme için bir, aktarma için bir?
İki sebeple. Birincisi hız: 5 MB'lık bir dosyayı iki kez göndermek kullanıcıyı bekletir. İkincisi ve asıl önemlisi tutarlılık: iki ayrı yükleme arasında dosya değişebilir. Önizlemede gördüğünüz veri ile aktarılan verinin aynı dosya olduğunu garanti etmenin tek yolu, dosyayı bir kez alıp bir token'la işaret etmektir.
Token oturuma bağlıdır ve IMPORT_TTL_MIN (30 dakika) sonra geçersizleşir. Süre dolarsa sunucu 410 döner ve kullanıcıdan yeniden yüklemesini ister.
Kodlama tespiti nasıl çalışıyor? Neden mb_detect_encoding() yetmiyor?
mb_detect_encoding() tek başına güvenilmezdir: ISO-8859-9 ile Windows-1254 arasında bayt düzeyinde ayrım yapılamaz ve fonksiyon hemen her baytı "latin1" olarak tanır — yani geçerli UTF-8 dosyaları da yanlışlıkla çevirir.
Bu yüzden sıra tersine çevrilmiştir:
mb_check_encoding($raw, 'UTF-8') geçiyorsa UTF-8'dir (ASCII de öyledir)Çeviri son çaredir, ilk tahmin değil. Türkçe Excel'in ürettiği dosyalar 3. adıma düşer ve doğru okunur.
Neden 5000 satır sınırı var? Daha büyük dosyaları nasıl aktarırım?
Sınırın sebebi kimlik doğrulaması olmayan bir uç noktaya sınırsız iş yaptırmamaktır. 100.000 satırlık bir dosya, tek bir istekte hem belleği hem işlemciyi tüketir; bu ucuz bir hizmet dışı bırakma kaldıracıdır.
Daha büyük dosyalar için üç yol var:
IMPORT_MAX_ROWS ve IMPORT_MAX_BYTES değerlerini yükseltin (kimlik doğrulaması olan bir panelde makul)import_runs her parçayı ayrı künyede tutarLOAD DATA INFILE kullanın; ama o zaman satır bazlı doğrulamadan ve önizlemeden vazgeçersinizAtomik mod gerçekten "hepsi ya da hiçbiri" mi?
Evet. Tüm satırlar tek bir transaction içinde işlenir; ilk hatada rollBack() çağrılır ve o ana kadar yazılan her şey geri alınır. Künye satırı da aynı transaction'da olduğu için o da silinir — bu yüzden hemen ardından ayrı bir aborted künyesi yazılır.
Bir uyarı: MySQL'de ALTER TABLE gibi DDL komutları örtük commit yapar. Bu kod yalnızca INSERT/UPDATE çalıştırdığı için sorun yoktur, ama içe aktarma sırasında şema değiştiren bir kod eklerseniz atomiklik garantisi kırılır.
Telefon numarasını neden değiştiriyorsunuz? Ham hâlini saklasam olmaz mı?
Aynı numara bir CSV'de altı farklı biçimde yazılabilir: 0532 111 22 33, +90 532 111 22 33, 905321112233, 532-111-22-33… Bunları olduğu gibi saklamak, "bu iki kayıt aynı kişi mi?" sorusunu sonsuza dek cevapsız bırakır ve telefonla arama yapmayı imkânsızlaştırır.
Normalizasyon kayıp veri değil, kanonik biçimdir: rakam dışı karakterler zaten bilgi taşımıyordu. Ekranda gösterirken format_phone() ile okunur hâle geri döndürülür.
10 haneye oturmayan numaralar hata sayılmaz: uyarıyla birlikte olduğu gibi saklanır, çünkü eksik bir telefon yüzünden geçerli bir kişi kaydını çöpe atmak veriyi düzeltmez — sadece kaybeder.
Hata raporu neden ayrı bir CSV? Ekranda göstersek yetmez mi?
Ekranda göstermek "kaç satır hatalı" sorusunu cevaplar; rapor ise düzeltmeyi mümkün kılar. Kullanıcı raporu indirir, Excel'de açar, satır numarasına ve sebebe bakarak kaynak dosyasını düzeltir ve yeniden yükler.
Rapor oturuma bağlı bir token'la korunur ve IMPORT_TTL_MIN sonra silinir. Formül enjeksiyonuna karşı hücreler csv_cell() ile korunur — bu dosya kullanıcının kendi (muhtemelen düşmanca) metnini geri yazdığı için burada koruma zorunludur.
dedupe=update mevcut kaydın hangi alanlarını değiştiriyor?
name, phone, city ve import_id. email değişmez — zaten eşleşme anahtarıdır. created_at de değişmez; kaydın ne zaman ilk girdiği bilgisi korunur, updated_at ise otomatik tazelenir.
Bu seçim bilinçlidir: bir güncelleme dosyası kaydın kimliğini değil, niteliklerini tazelemek içindir.
APP_DEBUG zaten ortamdan türetiliyor — yine de canlıda false olduğunu doğrulayınsystem/config.local.php oluşturun; künyeyi config.php'ye yazmayınroot yerine sınırlı yetkili bir kullanıcı oluşturunstorage/imports/ klasörünün web'den erişilemediğini doğrulayın (tarayıcıda deneyin)session.cookie_secure = 1 ve session.cookie_httponly = 1 ayarlayınIMPORT_MAX_ROWS / IMPORT_MAX_BYTES değerlerini sunucunuzun upload_max_filesize ve post_max_size ayarlarıyla uyumlu tutun.htaccess çalışmaz; şu iki yolu sunucu yapılandırmasından kapatın:```nginx
location ~* \.(sql|log|ini|bak)$ { deny all; }
location ^~ /storage/ { deny all; }
```
storage/imports/ birikmemeli)| Belirti | Çözüm |
|---|---|
| "Veritabanına bağlanılamadı" | MySQL çalışmıyor veya DB_* bilgileri hatalı. XAMPP panelinden MySQL'i başlatın. |
| "Dosya kaydedilemedi" | storage/imports/ yok ya da yazılabilir değil. Klasörü oluşturup izin verin. |
| "Yalnızca CSV/metin dosyası kabul edilir" | finfo dosyayı metin olarak tanımadı. Genelde gerçekten CSV olmayan (xlsx, zip) bir dosya seçilmiştir. fileinfo eklentisinin açık olduğunu da kontrol edin. |
| Türkçe karakterler önizlemede bozuk | Kodlama tespiti kaçırmış olabilir. Dosyayı Excel'de "CSV UTF-8" olarak yeniden kaydedip deneyin; künyedeki Kodlama alanı ne dediğine bakın. |
| Tüm satırlar tek sütuna düşmüş | Ayraç yanlış tespit edilmiş. Künyedeki Ayraç alanına bakın; dosyanın ilk satırında ayraç hiç geçmiyorsa varsayılan , kullanılır. |
| HTTP 410 "dosya bulunamadı" | Geçici dosyanın 30 dakikalık ömrü dolmuş. Dosyayı yeniden yükleyin. |
| HTTP 409 dönüyor | Bu bir arıza değil: atomik fren. Dosyada hatalı satır var ve hiçbir şey yazılmadı. Kısmi moda geçin ya da dosyayı düzeltin. |
| HTTP 403 dönüyor | Oturum düşmüş — sayfayı yenileyin. Sunucuda session.save_path yazılabilir olmalıdır. |
| CSV Excel'de bozuk görünüyor | İndirilen dosya UTF-8 BOM'lu ve ; ayraçlıdır. "Metni Sütunlara Dönüştür" ile açtıysanız ayracı ; seçin. |
$ is not defined | JavaScript yükleme sırası bozulmuş. jQuery her zaman en başta gelmelidir. |
| DataTables "Requested unknown parameter" | <th> sayısı ile sunucudan dönen dizi uzunluğu farklı. İkisini eşitleyin. |
| Örnek dosya düğmesi 404 veriyor | sample.csv proje kökünde olmalı; ZIP'ten çıkarırken atlanmış olabilir. |
detect_delimiter, csv_to_utf8, normalize_phone, validate_row)Bu proje herkese açıktır — dilediğiniz geliştirmeyle katkı sağlayabilirsiniz.
📦 Depo: github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables
| Nasıl katkı sağlarım? | Nereden |
|---|---|
| 🐛 Hata bildir | Issues |
| 💡 Özellik öner | Issues |
| 🔧 Kod gönder | Pull Requests |
| ❓ Soru sor | Discussions |
preview ve commit aynı doğrulama fonksiyonunu kullanmalıdır. İkisini ayıran PR kabul edilmez — önizlemenin doğruluğu projenin tezidir.style.css üzerinden yapın; cilginyazilim.css markaya aittir ve diğer projelerle ortaktır.MIT — ticari kullanım dahil serbesttir.
cilginyazilim.com tarafından ❤ ile geliştirildi
İncelemek istediğiniz dosyayı ağaçtan seçin.
Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).
Yüklenen dosyanın tamamını, gerçek içe aktarmayla birebir aynı doğrulama fonksiyonundan geçirir ama tek bir INSERT çalıştırmaz. Sonuçta her satırın ne olacağını gösteren bir özet çıkar: kaç satır temiz, kaç tanesi uyarılı, kaç tanesi hatalı, kaç tanesi dosya içinde veya veritabanında tekrar ediyor. Bu simetri bilinçlidir: önizleme ayrı bir kod kullansaydı zamanla ikisi ayrışır ve "önizlemede temizdi ama aktarırken patladı" durumu doğardı.
Atomik modda tüm satırlar tek bir transaction içinde işlenir ve ilk hatada her şey geri alınır; HTTP 409 döner ve veritabanına hiçbir satır girmez. Muhasebe ve finans gibi "yarım veri, yanlış veridir" durumları için uygundur. Kısmi modda geçerli satırlar yazılır, hatalılar atlanır ve satır numarasıyla birlikte indirilebilir bir CSV raporuna düşer. Büyük ve kirli dosyalarda tercih edilir, çünkü yüzde ikilik bozuk veri yüzünden yüzde doksan sekizlik temiz veriyi reddetmez.
Excel'in CSV kaydetme seçenekleri dosyayı çoğu zaman UTF-8 değil Windows-1254 olarak yazar; bu dosya doğrudan okunduğunda Türkçe harfler bozulur ve json_encode() geçersiz baytta sessizce false döner. Uygulama sırayla üç şeye bakar: önce UTF-8 BOM var mı, sonra içerik geçerli UTF-8 olarak çözümleniyor mu, ve ancak ikisi de olmazsa Windows-1254 varsayıp çevirir. Çeviri son çaredir; mb_detect_encoding() tek başına kullanılsaydı geçerli UTF-8 dosyaları da yanlışlıkla çevrilirdi.
Hayır. Asıl koruma uygulama kodunda değil, e-posta sütunundaki benzersiz indekstedir. "Önce SELECT, yoksa INSERT" kontrolü iki eşzamanlı istekte yarışır ve kaybeder; veritabanı kısıtı kaybetmez. Kod yine de önce SELECT yapar ama bunu önizleme için yapar, yani kullanıcıya "bu üç satır zaten var" diyebilmek için. Yazma anındaki gerçek koruma indeksin kendisidir ve dönen hata, indeks adına bakılarak diğer kısıt ihlallerinden ayırt edilir.
Dosyalar web'e kapalı bir klasöre (storage/imports) alınır ve adları oturum kimliğiyle öneklenir; token yalnızca onaltılık olabildiği için yol manipülasyonu mümkün değildir. Dosya türü uzantıdan değil içerikten okunur ve metin dışı türler reddedilir. İçe aktarma bittiğinde kaynak dosya hemen silinir; sahipsiz kalanlar otuz dakika sonra her istekte çalışan fırsatçı temizlikle kaldırılır. Dosya içeriği hiçbir zaman çalıştırılmaz, yalnızca okunur.
Dört yer: config.php içindeki TARGET_FIELDS ve REQUIRED_FIELDS sabitleri, function.php içindeki validate_row() doğrulama kuralları ve field_label() etiketleri, ajax.php içindeki INSERT ve UPDATE sorguları, ve benzersizlik anahtarınız (bu örnekte e-posta, sizde stok kodu olabilir) — indeks adını hem şemada hem hata ayıklama kontrolünde güncelleyin. İçe aktarma künyesi tablosu alanlarınızdan bağımsızdır, hiç değiştirmeden kullanabilirsiniz. Değiştirmemeniz gereken tek şey, önizleme ile aktarmanın aynı doğrulama fonksiyonunu çağırmasıdır.
Bu örneğin daha gelişmişi projenizde mi lazım?
İhtiyacınızı anlatın; bu yapıyı sizin sisteminize uyarlayalım ya da size özel geliştirelim.
Henüz yorum yok. Takıldığınız bir yer varsa ilk soruyu siz sorun.
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.
Orta
veri-isleme/excel-ice-disa-aktarma
173 inceleme 90 canlı açılış 3 beğeni
Orta
veri-isleme/php-soft-delete-trash
145 inceleme 53 canlı açılış
İleri
uygulama-iskeleti/php-baslangic-sablonu
311 inceleme 152 canlı açılış 6 beğeni 2 yorum